From 72077908f19c599fff1d7f721db36bb418375be6 Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Mon, 27 Jul 2026 21:01:23 +0330 Subject: [PATCH] feat(delivery): deploy backend phases 10.2-10.8 through settlement Ship fleet, availability, pricing, dispatch, routing, tracking, and settlement on delivery-service 0.10.8.0 with migrations and phase tests green. Co-authored-by: Cursor --- .../alembic/versions/0003_phase_102_fleet.py | 29 ++ .../versions/0004_phase_103_availability.py | 29 ++ .../versions/0005_phase_104_pricing.py | 28 ++ .../versions/0006_phase_105_dispatch.py | 27 ++ .../versions/0007_phase_106_routing.py | 28 ++ .../0008_phase_107_108_tracking_settlement.py | 31 ++ backend/services/delivery/app/__init__.py | 2 +- .../services/delivery/app/api/v1/__init__.py | 62 +++ .../delivery/app/api/v1/availability.py | 40 ++ .../services/delivery/app/api/v1/bundles.py | 100 ++++ .../delivery/app/api/v1/capabilities.py | 100 ++++ .../delivery/app/api/v1/customer_tracking.py | 88 ++++ .../app/api/v1/dispatch_assignments.py | 94 ++++ .../delivery/app/api/v1/dispatch_jobs.py | 120 +++++ .../services/delivery/app/api/v1/fleets.py | 102 ++++ .../services/delivery/app/api/v1/health.py | 52 +- .../delivery/app/api/v1/optimization.py | 74 +++ .../delivery/app/api/v1/pricing_rules.py | 103 ++++ .../delivery/app/api/v1/proof_of_delivery.py | 90 ++++ .../services/delivery/app/api/v1/routes.py | 134 ++++++ .../delivery/app/api/v1/settlements.py | 169 +++++++ .../services/delivery/app/api/v1/shifts.py | 156 ++++++ .../services/delivery/app/api/v1/tracking.py | 126 +++++ .../delivery/app/api/v1/vehicle_types.py | 96 ++++ .../services/delivery/app/api/v1/vehicles.py | 154 ++++++ .../delivery/app/api/v1/working_zones.py | 105 ++++ .../delivery/app/commands/availability.py | 90 ++++ .../delivery/app/commands/dispatch.py | 62 +++ .../services/delivery/app/commands/fleet.py | 92 ++++ .../services/delivery/app/commands/pricing.py | 69 +++ .../services/delivery/app/commands/routing.py | 51 ++ .../delivery/app/commands/settlement.py | 85 ++++ .../delivery/app/commands/tracking.py | 84 ++++ backend/services/delivery/app/events/types.py | 72 +++ .../services/delivery/app/models/__init__.py | 21 + .../delivery/app/models/availability.py | 142 ++++++ .../services/delivery/app/models/dispatch.py | 89 ++++ backend/services/delivery/app/models/fleet.py | 126 +++++ .../services/delivery/app/models/pricing.py | 108 +++++ .../services/delivery/app/models/routing.py | 110 +++++ .../delivery/app/models/settlement.py | 78 +++ .../services/delivery/app/models/tracking.py | 138 ++++++ backend/services/delivery/app/models/types.py | 159 ++++++ .../delivery/app/permissions/definitions.py | 233 ++++++++- .../delivery/app/policies/dispatch.py | 28 ++ .../delivery/app/policies/settlement.py | 26 + .../delivery/app/queries/availability.py | 38 ++ .../services/delivery/app/queries/dispatch.py | 38 ++ .../services/delivery/app/queries/fleet.py | 43 ++ .../services/delivery/app/queries/pricing.py | 44 ++ .../services/delivery/app/queries/routing.py | 41 ++ .../delivery/app/queries/settlement.py | 32 ++ .../services/delivery/app/queries/tracking.py | 68 +++ .../delivery/app/repositories/availability.py | 145 ++++++ .../delivery/app/repositories/dispatch.py | 107 +++++ .../delivery/app/repositories/fleet.py | 184 +++++++ .../delivery/app/repositories/pricing.py | 158 ++++++ .../delivery/app/repositories/routing.py | 115 +++++ .../delivery/app/repositories/settlement.py | 75 +++ .../delivery/app/repositories/tracking.py | 164 +++++++ .../delivery/app/schemas/availability.py | 172 +++++++ .../services/delivery/app/schemas/dispatch.py | 122 +++++ .../services/delivery/app/schemas/fleet.py | 184 +++++++ .../services/delivery/app/schemas/pricing.py | 153 ++++++ .../services/delivery/app/schemas/routing.py | 120 +++++ .../delivery/app/schemas/settlement.py | 99 ++++ .../services/delivery/app/schemas/tracking.py | 156 ++++++ .../delivery/app/services/_helpers.py | 29 ++ .../delivery/app/services/availability.py | 395 +++++++++++++++ .../delivery/app/services/dispatch.py | 281 +++++++++++ .../services/delivery/app/services/fleet.py | 452 ++++++++++++++++++ .../services/delivery/app/services/pricing.py | 330 +++++++++++++ .../services/delivery/app/services/routing.py | 234 +++++++++ .../delivery/app/services/settlement.py | 328 +++++++++++++ .../delivery/app/services/tracking.py | 344 +++++++++++++ .../app/specifications/availability.py | 77 +++ .../delivery/app/specifications/dispatch.py | 79 +++ .../delivery/app/specifications/fleet.py | 103 ++++ .../delivery/app/specifications/pricing.py | 108 +++++ .../delivery/app/specifications/routing.py | 71 +++ .../delivery/app/specifications/settlement.py | 45 ++ .../delivery/app/specifications/tracking.py | 99 ++++ .../services/delivery/app/tests/test_api.py | 8 +- .../delivery/app/tests/test_architecture.py | 110 +++-- .../delivery/app/tests/test_migration.py | 44 +- .../delivery/app/tests/test_phase_102.py | 93 ++++ .../delivery/app/tests/test_phase_103.py | 83 ++++ .../delivery/app/tests/test_phase_104.py | 67 +++ .../delivery/app/tests/test_phase_105.py | 73 +++ .../delivery/app/tests/test_phase_106.py | 46 ++ .../delivery/app/tests/test_phase_107.py | 73 +++ .../delivery/app/tests/test_phase_108.py | 80 ++++ .../delivery/app/validators/availability.py | 36 ++ .../delivery/app/validators/dispatch.py | 84 ++++ .../services/delivery/app/validators/fleet.py | 27 ++ .../delivery/app/validators/pricing.py | 38 ++ .../delivery/app/validators/routing.py | 25 + .../delivery/app/validators/settlement.py | 66 +++ .../delivery/app/validators/tracking.py | 30 ++ .../delivery/scripts/build_all_phases.py | 38 ++ .../delivery/scripts/gen_phases_102_108.py | 126 +++++ .../scripts/generate_phases_102_108.py | 209 ++++++++ docs/ai-framework/phase-manifest.yaml | 41 +- docs/ai-framework/project-index.yaml | 7 + docs/delivery-phase-10-8.md | 50 ++ docs/delivery-roadmap.md | 16 +- docs/module-registry.md | 48 +- docs/next-steps.md | 2 +- docs/phase-handover/phase-10-8.md | 105 ++++ docs/phases/Delivery/README.md | 16 +- docs/progress.md | 17 +- docs/project-status.yaml | 32 +- docs/service-snapshots/delivery.yaml | 188 ++++++++ 113 files changed, 10964 insertions(+), 149 deletions(-) create mode 100644 backend/services/delivery/alembic/versions/0003_phase_102_fleet.py create mode 100644 backend/services/delivery/alembic/versions/0004_phase_103_availability.py create mode 100644 backend/services/delivery/alembic/versions/0005_phase_104_pricing.py create mode 100644 backend/services/delivery/alembic/versions/0006_phase_105_dispatch.py create mode 100644 backend/services/delivery/alembic/versions/0007_phase_106_routing.py create mode 100644 backend/services/delivery/alembic/versions/0008_phase_107_108_tracking_settlement.py create mode 100644 backend/services/delivery/app/api/v1/availability.py create mode 100644 backend/services/delivery/app/api/v1/bundles.py create mode 100644 backend/services/delivery/app/api/v1/capabilities.py create mode 100644 backend/services/delivery/app/api/v1/customer_tracking.py create mode 100644 backend/services/delivery/app/api/v1/dispatch_assignments.py create mode 100644 backend/services/delivery/app/api/v1/dispatch_jobs.py create mode 100644 backend/services/delivery/app/api/v1/fleets.py create mode 100644 backend/services/delivery/app/api/v1/optimization.py create mode 100644 backend/services/delivery/app/api/v1/pricing_rules.py create mode 100644 backend/services/delivery/app/api/v1/proof_of_delivery.py create mode 100644 backend/services/delivery/app/api/v1/routes.py create mode 100644 backend/services/delivery/app/api/v1/settlements.py create mode 100644 backend/services/delivery/app/api/v1/shifts.py create mode 100644 backend/services/delivery/app/api/v1/tracking.py create mode 100644 backend/services/delivery/app/api/v1/vehicle_types.py create mode 100644 backend/services/delivery/app/api/v1/vehicles.py create mode 100644 backend/services/delivery/app/api/v1/working_zones.py create mode 100644 backend/services/delivery/app/commands/availability.py create mode 100644 backend/services/delivery/app/commands/dispatch.py create mode 100644 backend/services/delivery/app/commands/fleet.py create mode 100644 backend/services/delivery/app/commands/pricing.py create mode 100644 backend/services/delivery/app/commands/routing.py create mode 100644 backend/services/delivery/app/commands/settlement.py create mode 100644 backend/services/delivery/app/commands/tracking.py create mode 100644 backend/services/delivery/app/models/availability.py create mode 100644 backend/services/delivery/app/models/dispatch.py create mode 100644 backend/services/delivery/app/models/fleet.py create mode 100644 backend/services/delivery/app/models/pricing.py create mode 100644 backend/services/delivery/app/models/routing.py create mode 100644 backend/services/delivery/app/models/settlement.py create mode 100644 backend/services/delivery/app/models/tracking.py create mode 100644 backend/services/delivery/app/policies/dispatch.py create mode 100644 backend/services/delivery/app/policies/settlement.py create mode 100644 backend/services/delivery/app/queries/availability.py create mode 100644 backend/services/delivery/app/queries/dispatch.py create mode 100644 backend/services/delivery/app/queries/fleet.py create mode 100644 backend/services/delivery/app/queries/pricing.py create mode 100644 backend/services/delivery/app/queries/routing.py create mode 100644 backend/services/delivery/app/queries/settlement.py create mode 100644 backend/services/delivery/app/queries/tracking.py create mode 100644 backend/services/delivery/app/repositories/availability.py create mode 100644 backend/services/delivery/app/repositories/dispatch.py create mode 100644 backend/services/delivery/app/repositories/fleet.py create mode 100644 backend/services/delivery/app/repositories/pricing.py create mode 100644 backend/services/delivery/app/repositories/routing.py create mode 100644 backend/services/delivery/app/repositories/settlement.py create mode 100644 backend/services/delivery/app/repositories/tracking.py create mode 100644 backend/services/delivery/app/schemas/availability.py create mode 100644 backend/services/delivery/app/schemas/dispatch.py create mode 100644 backend/services/delivery/app/schemas/fleet.py create mode 100644 backend/services/delivery/app/schemas/pricing.py create mode 100644 backend/services/delivery/app/schemas/routing.py create mode 100644 backend/services/delivery/app/schemas/settlement.py create mode 100644 backend/services/delivery/app/schemas/tracking.py create mode 100644 backend/services/delivery/app/services/_helpers.py create mode 100644 backend/services/delivery/app/services/availability.py create mode 100644 backend/services/delivery/app/services/dispatch.py create mode 100644 backend/services/delivery/app/services/fleet.py create mode 100644 backend/services/delivery/app/services/pricing.py create mode 100644 backend/services/delivery/app/services/routing.py create mode 100644 backend/services/delivery/app/services/settlement.py create mode 100644 backend/services/delivery/app/services/tracking.py create mode 100644 backend/services/delivery/app/specifications/availability.py create mode 100644 backend/services/delivery/app/specifications/dispatch.py create mode 100644 backend/services/delivery/app/specifications/fleet.py create mode 100644 backend/services/delivery/app/specifications/pricing.py create mode 100644 backend/services/delivery/app/specifications/routing.py create mode 100644 backend/services/delivery/app/specifications/settlement.py create mode 100644 backend/services/delivery/app/specifications/tracking.py create mode 100644 backend/services/delivery/app/tests/test_phase_102.py create mode 100644 backend/services/delivery/app/tests/test_phase_103.py create mode 100644 backend/services/delivery/app/tests/test_phase_104.py create mode 100644 backend/services/delivery/app/tests/test_phase_105.py create mode 100644 backend/services/delivery/app/tests/test_phase_106.py create mode 100644 backend/services/delivery/app/tests/test_phase_107.py create mode 100644 backend/services/delivery/app/tests/test_phase_108.py create mode 100644 backend/services/delivery/app/validators/availability.py create mode 100644 backend/services/delivery/app/validators/dispatch.py create mode 100644 backend/services/delivery/app/validators/fleet.py create mode 100644 backend/services/delivery/app/validators/pricing.py create mode 100644 backend/services/delivery/app/validators/routing.py create mode 100644 backend/services/delivery/app/validators/settlement.py create mode 100644 backend/services/delivery/app/validators/tracking.py create mode 100644 backend/services/delivery/scripts/build_all_phases.py create mode 100644 backend/services/delivery/scripts/gen_phases_102_108.py create mode 100644 backend/services/delivery/scripts/generate_phases_102_108.py create mode 100644 docs/delivery-phase-10-8.md create mode 100644 docs/phase-handover/phase-10-8.md create mode 100644 docs/service-snapshots/delivery.yaml diff --git a/backend/services/delivery/alembic/versions/0003_phase_102_fleet.py b/backend/services/delivery/alembic/versions/0003_phase_102_fleet.py new file mode 100644 index 0000000..0268a0a --- /dev/null +++ b/backend/services/delivery/alembic/versions/0003_phase_102_fleet.py @@ -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) diff --git a/backend/services/delivery/alembic/versions/0004_phase_103_availability.py b/backend/services/delivery/alembic/versions/0004_phase_103_availability.py new file mode 100644 index 0000000..1a515d4 --- /dev/null +++ b/backend/services/delivery/alembic/versions/0004_phase_103_availability.py @@ -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) diff --git a/backend/services/delivery/alembic/versions/0005_phase_104_pricing.py b/backend/services/delivery/alembic/versions/0005_phase_104_pricing.py new file mode 100644 index 0000000..630cd9f --- /dev/null +++ b/backend/services/delivery/alembic/versions/0005_phase_104_pricing.py @@ -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) diff --git a/backend/services/delivery/alembic/versions/0006_phase_105_dispatch.py b/backend/services/delivery/alembic/versions/0006_phase_105_dispatch.py new file mode 100644 index 0000000..88fa63b --- /dev/null +++ b/backend/services/delivery/alembic/versions/0006_phase_105_dispatch.py @@ -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) diff --git a/backend/services/delivery/alembic/versions/0007_phase_106_routing.py b/backend/services/delivery/alembic/versions/0007_phase_106_routing.py new file mode 100644 index 0000000..66b97b5 --- /dev/null +++ b/backend/services/delivery/alembic/versions/0007_phase_106_routing.py @@ -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) diff --git a/backend/services/delivery/alembic/versions/0008_phase_107_108_tracking_settlement.py b/backend/services/delivery/alembic/versions/0008_phase_107_108_tracking_settlement.py new file mode 100644 index 0000000..252f315 --- /dev/null +++ b/backend/services/delivery/alembic/versions/0008_phase_107_108_tracking_settlement.py @@ -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) diff --git a/backend/services/delivery/app/__init__.py b/backend/services/delivery/app/__init__.py index c0bbe9d..fa829e3 100644 --- a/backend/services/delivery/app/__init__.py +++ b/backend/services/delivery/app/__init__.py @@ -1 +1 @@ -__version__ = "0.10.1.0" +__version__ = "0.10.8.0" diff --git a/backend/services/delivery/app/api/v1/__init__.py b/backend/services/delivery/app/api/v1/__init__.py index 826473c..0e928b7 100644 --- a/backend/services/delivery/app/api/v1/__init__.py +++ b/backend/services/delivery/app/api/v1/__init__.py @@ -2,14 +2,31 @@ from fastapi import APIRouter from app.api.v1 import ( audit, + availability, + bundles, + capabilities, configurations, + customer_tracking, + dispatch_assignments, + dispatch_jobs, drivers, external_providers, + fleets, hubs, + optimization, organizations, permissions, + pricing_rules, + proof_of_delivery, + routes, routing_engines, settings, + settlements, + shifts, + tracking, + vehicle_types, + vehicles, + working_zones, ) api_router = APIRouter() @@ -31,6 +48,51 @@ api_router.include_router( api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) api_router.include_router(audit.router, prefix="/audit", tags=["audit"]) api_router.include_router(drivers.router, prefix="/drivers", tags=["drivers"]) +api_router.include_router( + availability.router, prefix="/drivers", tags=["driver-availability"] +) +api_router.include_router(fleets.router, prefix="/fleets", tags=["fleets"]) +api_router.include_router( + vehicle_types.router, prefix="/vehicle-types", tags=["vehicle-types"] +) +api_router.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"]) +api_router.include_router(shifts.router, prefix="/shifts", tags=["shifts"]) +api_router.include_router( + working_zones.router, prefix="/working-zones", tags=["working-zones"] +) +api_router.include_router( + pricing_rules.router, prefix="/pricing-rules", tags=["pricing-rules"] +) +api_router.include_router( + capabilities.router, prefix="/capabilities", tags=["capabilities"] +) +api_router.include_router(bundles.router, prefix="/bundles", tags=["bundles"]) +api_router.include_router( + dispatch_jobs.router, prefix="/dispatch/jobs", tags=["dispatch-jobs"] +) +api_router.include_router( + dispatch_assignments.router, + prefix="/dispatch/assignments", + tags=["dispatch-assignments"], +) +api_router.include_router(routes.router, prefix="/routes", tags=["routes"]) +api_router.include_router( + optimization.router, prefix="/optimization", tags=["optimization"] +) +api_router.include_router(tracking.router, prefix="/tracking", tags=["tracking"]) +api_router.include_router( + proof_of_delivery.router, + prefix="/proof-of-delivery", + tags=["proof-of-delivery"], +) +api_router.include_router( + customer_tracking.router, + prefix="/customer-tracking", + tags=["customer-tracking"], +) +api_router.include_router( + settlements.router, prefix="/settlements", tags=["settlements"] +) api_router.include_router( permissions.router, prefix="/permissions", tags=["permissions"] ) diff --git a/backend/services/delivery/app/api/v1/availability.py b/backend/services/delivery/app/api/v1/availability.py new file mode 100644 index 0000000..e6d45dc --- /dev/null +++ b/backend/services/delivery/app/api/v1/availability.py @@ -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 + ) diff --git a/backend/services/delivery/app/api/v1/bundles.py b/backend/services/delivery/app/api/v1/bundles.py new file mode 100644 index 0000000..5b23658 --- /dev/null +++ b/backend/services/delivery/app/api/v1/bundles.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/capabilities.py b/backend/services/delivery/app/api/v1/capabilities.py new file mode 100644 index 0000000..624c96e --- /dev/null +++ b/backend/services/delivery/app/api/v1/capabilities.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/customer_tracking.py b/backend/services/delivery/app/api/v1/customer_tracking.py new file mode 100644 index 0000000..1ecdfb5 --- /dev/null +++ b/backend/services/delivery/app/api/v1/customer_tracking.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/dispatch_assignments.py b/backend/services/delivery/app/api/v1/dispatch_assignments.py new file mode 100644 index 0000000..1998e7f --- /dev/null +++ b/backend/services/delivery/app/api/v1/dispatch_assignments.py @@ -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 + ) diff --git a/backend/services/delivery/app/api/v1/dispatch_jobs.py b/backend/services/delivery/app/api/v1/dispatch_jobs.py new file mode 100644 index 0000000..ee740f7 --- /dev/null +++ b/backend/services/delivery/app/api/v1/dispatch_jobs.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/fleets.py b/backend/services/delivery/app/api/v1/fleets.py new file mode 100644 index 0000000..e1dda14 --- /dev/null +++ b/backend/services/delivery/app/api/v1/fleets.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/health.py b/backend/services/delivery/app/api/v1/health.py index 53005ed..23c0fe7 100644 --- a/backend/services/delivery/app/api/v1/health.py +++ b/backend/services/delivery/app/api/v1/health.py @@ -20,7 +20,7 @@ async def capabilities(): return { "service": settings.service_name, "version": __version__, - "phase": "10.1", + "phase": "10.8", "commercial_product": "Torbat Driver", "features": { "foundation": True, @@ -33,12 +33,23 @@ async def capabilities(): "driver_lifecycle": True, "driver_credentials": True, "driver_documents": True, - "fleet": False, - "dispatch_engine": False, - "routing_engine": False, - "tracking": False, - "proof_of_delivery": False, - "settlement": False, + "fleet": True, + "vehicle_types": True, + "vehicles": True, + "vehicle_assignments": True, + "driver_availability": True, + "shifts": True, + "working_zones": True, + "pricing_rules": True, + "capabilities_catalog": True, + "capability_bundles": True, + "dispatch_engine": True, + "routing_engine": True, + "optimization_runs": True, + "tracking": True, + "proof_of_delivery": True, + "customer_tracking": True, + "settlement": True, "ai": False, }, "independence": { @@ -56,31 +67,34 @@ async def capabilities(): "pharmacy", ], "integration_mode": "api_and_events_only", + "accounting_mode": "settlement_refs_only", + "no_journal_entries_in_delivery_db": True, }, } @router.get("/metrics") async def metrics(): - """Phase 10.1 metrics discovery — driver counters reserved for ops wiring.""" + """Phase 10.8 metrics discovery — tenant-scoped counters.""" return { "service": settings.service_name, "version": __version__, - "phase": "10.1", + "phase": "10.8", "metrics": { "organizations": "tenant_scoped", "hubs": "tenant_scoped", - "external_providers": "tenant_scoped", - "routing_engines": "tenant_scoped", - "configurations": "tenant_scoped", "drivers": "tenant_scoped", - "driver_credentials": "tenant_scoped", - "driver_documents": "tenant_scoped", - "driver_lifecycle_events": "tenant_scoped", + "fleets": "tenant_scoped", + "vehicles": "tenant_scoped", + "shifts": "tenant_scoped", + "pricing_rules": "tenant_scoped", + "dispatch_jobs": "tenant_scoped", + "route_plans": "tenant_scoped", + "optimization_runs": "tenant_scoped", + "tracking_sessions": "tenant_scoped", + "proof_of_delivery": "tenant_scoped", + "settlement_intents": "tenant_scoped", "outbox_events": "tenant_scoped", - "dispatch_jobs": 0, - "active_routes": 0, - "tracking_sessions": 0, }, - "note": "Driver aggregates live; dispatch/routing/tracking counters reserved for later phases", + "note": "Full delivery platform through phase 10.8; settlement uses AccountingProvider refs only", } diff --git a/backend/services/delivery/app/api/v1/optimization.py b/backend/services/delivery/app/api/v1/optimization.py new file mode 100644 index 0000000..fbacc37 --- /dev/null +++ b/backend/services/delivery/app/api/v1/optimization.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/pricing_rules.py b/backend/services/delivery/app/api/v1/pricing_rules.py new file mode 100644 index 0000000..bbba359 --- /dev/null +++ b/backend/services/delivery/app/api/v1/pricing_rules.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/proof_of_delivery.py b/backend/services/delivery/app/api/v1/proof_of_delivery.py new file mode 100644 index 0000000..0f664a3 --- /dev/null +++ b/backend/services/delivery/app/api/v1/proof_of_delivery.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/routes.py b/backend/services/delivery/app/api/v1/routes.py new file mode 100644 index 0000000..80b81c6 --- /dev/null +++ b/backend/services/delivery/app/api/v1/routes.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/settlements.py b/backend/services/delivery/app/api/v1/settlements.py new file mode 100644 index 0000000..316a923 --- /dev/null +++ b/backend/services/delivery/app/api/v1/settlements.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/shifts.py b/backend/services/delivery/app/api/v1/shifts.py new file mode 100644 index 0000000..5c81ebb --- /dev/null +++ b/backend/services/delivery/app/api/v1/shifts.py @@ -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 + ) diff --git a/backend/services/delivery/app/api/v1/tracking.py b/backend/services/delivery/app/api/v1/tracking.py new file mode 100644 index 0000000..a67b05a --- /dev/null +++ b/backend/services/delivery/app/api/v1/tracking.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/vehicle_types.py b/backend/services/delivery/app/api/v1/vehicle_types.py new file mode 100644 index 0000000..64f88e3 --- /dev/null +++ b/backend/services/delivery/app/api/v1/vehicle_types.py @@ -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) diff --git a/backend/services/delivery/app/api/v1/vehicles.py b/backend/services/delivery/app/api/v1/vehicles.py new file mode 100644 index 0000000..546d28e --- /dev/null +++ b/backend/services/delivery/app/api/v1/vehicles.py @@ -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 + ) diff --git a/backend/services/delivery/app/api/v1/working_zones.py b/backend/services/delivery/app/api/v1/working_zones.py new file mode 100644 index 0000000..ea370c7 --- /dev/null +++ b/backend/services/delivery/app/api/v1/working_zones.py @@ -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) diff --git a/backend/services/delivery/app/commands/availability.py b/backend/services/delivery/app/commands/availability.py new file mode 100644 index 0000000..916f623 --- /dev/null +++ b/backend/services/delivery/app/commands/availability.py @@ -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) diff --git a/backend/services/delivery/app/commands/dispatch.py b/backend/services/delivery/app/commands/dispatch.py new file mode 100644 index 0000000..09aa23c --- /dev/null +++ b/backend/services/delivery/app/commands/dispatch.py @@ -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) diff --git a/backend/services/delivery/app/commands/fleet.py b/backend/services/delivery/app/commands/fleet.py new file mode 100644 index 0000000..2686ff3 --- /dev/null +++ b/backend/services/delivery/app/commands/fleet.py @@ -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 + ) diff --git a/backend/services/delivery/app/commands/pricing.py b/backend/services/delivery/app/commands/pricing.py new file mode 100644 index 0000000..a9750fb --- /dev/null +++ b/backend/services/delivery/app/commands/pricing.py @@ -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) diff --git a/backend/services/delivery/app/commands/routing.py b/backend/services/delivery/app/commands/routing.py new file mode 100644 index 0000000..69d3307 --- /dev/null +++ b/backend/services/delivery/app/commands/routing.py @@ -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) diff --git a/backend/services/delivery/app/commands/settlement.py b/backend/services/delivery/app/commands/settlement.py new file mode 100644 index 0000000..9e200a9 --- /dev/null +++ b/backend/services/delivery/app/commands/settlement.py @@ -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) diff --git a/backend/services/delivery/app/commands/tracking.py b/backend/services/delivery/app/commands/tracking.py new file mode 100644 index 0000000..77cb2e4 --- /dev/null +++ b/backend/services/delivery/app/commands/tracking.py @@ -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) diff --git a/backend/services/delivery/app/events/types.py b/backend/services/delivery/app/events/types.py index 8f54b37..0b302eb 100644 --- a/backend/services/delivery/app/events/types.py +++ b/backend/services/delivery/app/events/types.py @@ -31,3 +31,75 @@ class DeliveryEventType(str, enum.Enum): DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added" DRIVER_DOCUMENT_ATTACHED = "delivery.driver.document_attached" DRIVER_STATUS_CHANGED = "delivery.driver.status_changed" + + # Phase 10.2 — Fleet & Vehicle Types + FLEET_CREATED = "delivery.fleet.created" + FLEET_UPDATED = "delivery.fleet.updated" + FLEET_DELETED = "delivery.fleet.deleted" + VEHICLE_TYPE_CREATED = "delivery.vehicle_type.created" + VEHICLE_TYPE_UPDATED = "delivery.vehicle_type.updated" + VEHICLE_TYPE_DELETED = "delivery.vehicle_type.deleted" + VEHICLE_CREATED = "delivery.vehicle.created" + VEHICLE_UPDATED = "delivery.vehicle.updated" + VEHICLE_DELETED = "delivery.vehicle.deleted" + VEHICLE_ASSIGNMENT_CREATED = "delivery.vehicle_assignment.created" + VEHICLE_ASSIGNMENT_UPDATED = "delivery.vehicle_assignment.updated" + VEHICLE_ASSIGNMENT_ENDED = "delivery.vehicle_assignment.ended" + + # Phase 10.3 — Availability, Shifts & Working Zones + DRIVER_AVAILABILITY_UPDATED = "delivery.driver_availability.updated" + SHIFT_CREATED = "delivery.shift.created" + SHIFT_UPDATED = "delivery.shift.updated" + SHIFT_DELETED = "delivery.shift.deleted" + SHIFT_ASSIGNMENT_CREATED = "delivery.shift_assignment.created" + SHIFT_ASSIGNMENT_UPDATED = "delivery.shift_assignment.updated" + WORKING_ZONE_CREATED = "delivery.working_zone.created" + WORKING_ZONE_UPDATED = "delivery.working_zone.updated" + WORKING_ZONE_DELETED = "delivery.working_zone.deleted" + + # Phase 10.4 — Pricing, Capabilities & Bundles + PRICING_RULE_CREATED = "delivery.pricing_rule.created" + PRICING_RULE_UPDATED = "delivery.pricing_rule.updated" + PRICING_RULE_DELETED = "delivery.pricing_rule.deleted" + CAPABILITY_CREATED = "delivery.capability.created" + CAPABILITY_UPDATED = "delivery.capability.updated" + CAPABILITY_DELETED = "delivery.capability.deleted" + BUNDLE_CREATED = "delivery.bundle.created" + BUNDLE_UPDATED = "delivery.bundle.updated" + BUNDLE_DELETED = "delivery.bundle.deleted" + + # Phase 10.5 — Dispatch Engine + DISPATCH_JOB_CREATED = "delivery.dispatch_job.created" + DISPATCH_JOB_UPDATED = "delivery.dispatch_job.updated" + DISPATCH_JOB_STATUS_CHANGED = "delivery.dispatch_job.status_changed" + DISPATCH_JOB_DELETED = "delivery.dispatch_job.deleted" + JOB_ASSIGNMENT_CREATED = "delivery.job_assignment.created" + JOB_ASSIGNMENT_UPDATED = "delivery.job_assignment.updated" + JOB_ASSIGNMENT_STATUS_CHANGED = "delivery.job_assignment.status_changed" + + # Phase 10.6 — Routing & Optimization + ROUTE_PLAN_CREATED = "delivery.route_plan.created" + ROUTE_PLAN_UPDATED = "delivery.route_plan.updated" + ROUTE_PLAN_DELETED = "delivery.route_plan.deleted" + ROUTE_STOP_ADDED = "delivery.route_stop.added" + OPTIMIZATION_RUN_STARTED = "delivery.optimization_run.started" + OPTIMIZATION_RUN_COMPLETED = "delivery.optimization_run.completed" + OPTIMIZATION_RUN_FAILED = "delivery.optimization_run.failed" + + # Phase 10.7 — Tracking & Proof of Delivery + TRACKING_SESSION_STARTED = "delivery.tracking_session.started" + TRACKING_SESSION_UPDATED = "delivery.tracking_session.updated" + TRACKING_SESSION_ENDED = "delivery.tracking_session.ended" + TRACKING_POINT_RECORDED = "delivery.tracking_point.recorded" + PROOF_OF_DELIVERY_CAPTURED = "delivery.proof_of_delivery.captured" + PROOF_OF_DELIVERY_UPDATED = "delivery.proof_of_delivery.updated" + CUSTOMER_TRACKING_TOKEN_CREATED = "delivery.customer_tracking_token.created" + CUSTOMER_TRACKING_TOKEN_REVOKED = "delivery.customer_tracking_token.revoked" + + # Phase 10.8 — Settlement + SETTLEMENT_INTENT_CREATED = "delivery.settlement_intent.created" + SETTLEMENT_INTENT_UPDATED = "delivery.settlement_intent.updated" + SETTLEMENT_INTENT_SUBMITTED = "delivery.settlement_intent.submitted" + SETTLEMENT_INTENT_SETTLED = "delivery.settlement_intent.settled" + SETTLEMENT_INTENT_FAILED = "delivery.settlement_intent.failed" + SETTLEMENT_LINE_ADDED = "delivery.settlement_line.added" diff --git a/backend/services/delivery/app/models/__init__.py b/backend/services/delivery/app/models/__init__.py index 3ce9d09..1b4c333 100644 --- a/backend/services/delivery/app/models/__init__.py +++ b/backend/services/delivery/app/models/__init__.py @@ -1,10 +1,18 @@ """Import all models for Alembic metadata discovery.""" +from app.models.availability import ( # noqa: F401 + DriverAvailability, + Shift, + ShiftAssignment, + WorkingZone, +) +from app.models.dispatch import DispatchJob, JobAssignment # noqa: F401 from app.models.drivers import ( # noqa: F401 Driver, DriverCredential, DriverDocument, DriverLifecycleEvent, ) +from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType # noqa: F401 from app.models.foundation import ( # noqa: F401 DeliveryAuditLog, DeliveryConfiguration, @@ -17,3 +25,16 @@ from app.models.foundation import ( # noqa: F401 RoutingEngineRegistration, ) from app.models.outbox import OutboxEvent # noqa: F401 +from app.models.pricing import ( # noqa: F401 + CapabilityBundle, + CapabilityDefinition, + PricingRule, +) +from app.models.routing import OptimizationRun, RoutePlan, RouteStop # noqa: F401 +from app.models.settlement import SettlementIntent, SettlementLine # noqa: F401 +from app.models.tracking import ( # noqa: F401 + CustomerTrackingToken, + ProofOfDelivery, + TrackingPoint, + TrackingSession, +) diff --git a/backend/services/delivery/app/models/availability.py b/backend/services/delivery/app/models/availability.py new file mode 100644 index 0000000..2eabba7 --- /dev/null +++ b/backend/services/delivery/app/models/availability.py @@ -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"), + ) diff --git a/backend/services/delivery/app/models/dispatch.py b/backend/services/delivery/app/models/dispatch.py new file mode 100644 index 0000000..9f753c6 --- /dev/null +++ b/backend/services/delivery/app/models/dispatch.py @@ -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"), + ) diff --git a/backend/services/delivery/app/models/fleet.py b/backend/services/delivery/app/models/fleet.py new file mode 100644 index 0000000..c7030bf --- /dev/null +++ b/backend/services/delivery/app/models/fleet.py @@ -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"), + ) diff --git a/backend/services/delivery/app/models/pricing.py b/backend/services/delivery/app/models/pricing.py new file mode 100644 index 0000000..d9546d7 --- /dev/null +++ b/backend/services/delivery/app/models/pricing.py @@ -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"), + ) diff --git a/backend/services/delivery/app/models/routing.py b/backend/services/delivery/app/models/routing.py new file mode 100644 index 0000000..7ed9655 --- /dev/null +++ b/backend/services/delivery/app/models/routing.py @@ -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"), + ) diff --git a/backend/services/delivery/app/models/settlement.py b/backend/services/delivery/app/models/settlement.py new file mode 100644 index 0000000..588561d --- /dev/null +++ b/backend/services/delivery/app/models/settlement.py @@ -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"), + ) diff --git a/backend/services/delivery/app/models/tracking.py b/backend/services/delivery/app/models/tracking.py new file mode 100644 index 0000000..9f6ff81 --- /dev/null +++ b/backend/services/delivery/app/models/tracking.py @@ -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"), + ) diff --git a/backend/services/delivery/app/models/types.py b/backend/services/delivery/app/models/types.py index ab90e40..557752f 100644 --- a/backend/services/delivery/app/models/types.py +++ b/backend/services/delivery/app/models/types.py @@ -116,3 +116,162 @@ class DriverDocumentKind(str, enum.Enum): CONTRACT = "contract" PHOTO = "photo" OTHER = "other" + + +# Phase 10.2 — Fleet & Vehicle Types +class FleetStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + ARCHIVED = "archived" + + +class VehicleStatus(str, enum.Enum): + AVAILABLE = "available" + ASSIGNED = "assigned" + MAINTENANCE = "maintenance" + INACTIVE = "inactive" + ARCHIVED = "archived" + + +class VehicleAssignmentStatus(str, enum.Enum): + ACTIVE = "active" + ENDED = "ended" + CANCELLED = "cancelled" + + +# Phase 10.3 — Availability, Shifts & Working Zones +class DriverAvailabilityStatus(str, enum.Enum): + ONLINE = "online" + OFFLINE = "offline" + BUSY = "busy" + BREAK = "break" + + +class ShiftStatus(str, enum.Enum): + DRAFT = "draft" + SCHEDULED = "scheduled" + ACTIVE = "active" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class ShiftAssignmentStatus(str, enum.Enum): + ASSIGNED = "assigned" + CHECKED_IN = "checked_in" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class WorkingZoneStatus(str, enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + +# Phase 10.4 — Pricing, Capabilities & Bundles +class PricingRuleStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + ARCHIVED = "archived" + + +class CapabilityStatus(str, enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + + +class CapabilityBundleStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + ARCHIVED = "archived" + + +# Phase 10.5 — Dispatch Engine +class DispatchJobStatus(str, enum.Enum): + PENDING = "pending" + ASSIGNED = "assigned" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + CANCELLED = "cancelled" + FAILED = "failed" + + +class DispatchJobAction(str, enum.Enum): + ASSIGN = "assign" + START = "start" + COMPLETE = "complete" + CANCEL = "cancel" + FAIL = "fail" + + +class JobAssignmentStatus(str, enum.Enum): + PENDING = "pending" + ACCEPTED = "accepted" + REJECTED = "rejected" + ACTIVE = "active" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +# Phase 10.6 — Routing & Optimization +class RoutePlanStatus(str, enum.Enum): + DRAFT = "draft" + PLANNED = "planned" + ACTIVE = "active" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class RouteStopStatus(str, enum.Enum): + PENDING = "pending" + ARRIVED = "arrived" + COMPLETED = "completed" + SKIPPED = "skipped" + + +class OptimizationRunStatus(str, enum.Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +# Phase 10.7 — Tracking & Proof of Delivery +class TrackingSessionStatus(str, enum.Enum): + ACTIVE = "active" + PAUSED = "paused" + COMPLETED = "completed" + CANCELLED = "cancelled" + + +class ProofOfDeliveryStatus(str, enum.Enum): + PENDING = "pending" + CAPTURED = "captured" + VERIFIED = "verified" + REJECTED = "rejected" + + +class CustomerTrackingTokenStatus(str, enum.Enum): + ACTIVE = "active" + EXPIRED = "expired" + REVOKED = "revoked" + + +# Phase 10.8 — Settlement +class SettlementIntentStatus(str, enum.Enum): + DRAFT = "draft" + PENDING = "pending" + SUBMITTED = "submitted" + SETTLED = "settled" + FAILED = "failed" + CANCELLED = "cancelled" + + +class SettlementLineKind(str, enum.Enum): + DELIVERY_FEE = "delivery_fee" + TIP = "tip" + SURCHARGE = "surcharge" + ADJUSTMENT = "adjustment" + OTHER = "other" diff --git a/backend/services/delivery/app/permissions/definitions.py b/backend/services/delivery/app/permissions/definitions.py index e035c7a..fa6b659 100644 --- a/backend/services/delivery/app/permissions/definitions.py +++ b/backend/services/delivery/app/permissions/definitions.py @@ -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 DELIVERY_VIEW = "delivery.view" @@ -58,18 +58,134 @@ DRIVERS_DOCUMENTS_VIEW = "delivery.drivers.documents.view" DRIVERS_DOCUMENTS_MANAGE = "delivery.drivers.documents.manage" DRIVERS_MANAGE = "delivery.drivers.manage" -# Planned trees (registered for discovery; enforced in later phases) -FLEET_VIEW = "delivery.fleet.view" -FLEET_MANAGE = "delivery.fleet.manage" +# Phase 10.2 — Fleet & Vehicles +FLEETS_VIEW = "delivery.fleets.view" +FLEETS_CREATE = "delivery.fleets.create" +FLEETS_UPDATE = "delivery.fleets.update" +FLEETS_DELETE = "delivery.fleets.delete" +FLEETS_MANAGE = "delivery.fleets.manage" + +VEHICLE_TYPES_VIEW = "delivery.vehicle_types.view" +VEHICLE_TYPES_CREATE = "delivery.vehicle_types.create" +VEHICLE_TYPES_UPDATE = "delivery.vehicle_types.update" +VEHICLE_TYPES_DELETE = "delivery.vehicle_types.delete" +VEHICLE_TYPES_MANAGE = "delivery.vehicle_types.manage" + +VEHICLES_VIEW = "delivery.vehicles.view" +VEHICLES_CREATE = "delivery.vehicles.create" +VEHICLES_UPDATE = "delivery.vehicles.update" +VEHICLES_DELETE = "delivery.vehicles.delete" +VEHICLES_ASSIGNMENTS_VIEW = "delivery.vehicles.assignments.view" +VEHICLES_ASSIGNMENTS_MANAGE = "delivery.vehicles.assignments.manage" +VEHICLES_MANAGE = "delivery.vehicles.manage" + +# Phase 10.3 — Availability, Shifts & Working Zones +AVAILABILITY_VIEW = "delivery.availability.view" +AVAILABILITY_MANAGE = "delivery.availability.manage" + +SHIFTS_VIEW = "delivery.shifts.view" +SHIFTS_CREATE = "delivery.shifts.create" +SHIFTS_UPDATE = "delivery.shifts.update" +SHIFTS_DELETE = "delivery.shifts.delete" +SHIFTS_ASSIGNMENTS_VIEW = "delivery.shifts.assignments.view" +SHIFTS_ASSIGNMENTS_MANAGE = "delivery.shifts.assignments.manage" +SHIFTS_MANAGE = "delivery.shifts.manage" + +WORKING_ZONES_VIEW = "delivery.working_zones.view" +WORKING_ZONES_CREATE = "delivery.working_zones.create" +WORKING_ZONES_UPDATE = "delivery.working_zones.update" +WORKING_ZONES_DELETE = "delivery.working_zones.delete" +WORKING_ZONES_MANAGE = "delivery.working_zones.manage" + +# Phase 10.4 — Pricing, Capabilities & Bundles +PRICING_RULES_VIEW = "delivery.pricing_rules.view" +PRICING_RULES_CREATE = "delivery.pricing_rules.create" +PRICING_RULES_UPDATE = "delivery.pricing_rules.update" +PRICING_RULES_DELETE = "delivery.pricing_rules.delete" +PRICING_RULES_MANAGE = "delivery.pricing_rules.manage" + +CAPABILITIES_VIEW = "delivery.capabilities.view" +CAPABILITIES_CREATE = "delivery.capabilities.create" +CAPABILITIES_UPDATE = "delivery.capabilities.update" +CAPABILITIES_DELETE = "delivery.capabilities.delete" +CAPABILITIES_MANAGE = "delivery.capabilities.manage" + +BUNDLES_VIEW = "delivery.bundles.view" +BUNDLES_CREATE = "delivery.bundles.create" +BUNDLES_UPDATE = "delivery.bundles.update" +BUNDLES_DELETE = "delivery.bundles.delete" +BUNDLES_MANAGE = "delivery.bundles.manage" + +# Phase 10.5 — Dispatch Engine +DISPATCH_JOBS_VIEW = "delivery.dispatch.jobs.view" +DISPATCH_JOBS_CREATE = "delivery.dispatch.jobs.create" +DISPATCH_JOBS_UPDATE = "delivery.dispatch.jobs.update" +DISPATCH_JOBS_DELETE = "delivery.dispatch.jobs.delete" +DISPATCH_JOBS_STATUS = "delivery.dispatch.jobs.status" +DISPATCH_JOBS_MANAGE = "delivery.dispatch.jobs.manage" + +DISPATCH_ASSIGNMENTS_VIEW = "delivery.dispatch.assignments.view" +DISPATCH_ASSIGNMENTS_CREATE = "delivery.dispatch.assignments.create" +DISPATCH_ASSIGNMENTS_UPDATE = "delivery.dispatch.assignments.update" +DISPATCH_ASSIGNMENTS_MANAGE = "delivery.dispatch.assignments.manage" + DISPATCH_VIEW = "delivery.dispatch.view" DISPATCH_MANAGE = "delivery.dispatch.manage" + +# Phase 10.6 — Routing & Optimization +ROUTES_VIEW = "delivery.routes.view" +ROUTES_CREATE = "delivery.routes.create" +ROUTES_UPDATE = "delivery.routes.update" +ROUTES_DELETE = "delivery.routes.delete" +ROUTES_STOPS_VIEW = "delivery.routes.stops.view" +ROUTES_STOPS_MANAGE = "delivery.routes.stops.manage" +ROUTES_MANAGE = "delivery.routes.manage" + +OPTIMIZATION_RUNS_VIEW = "delivery.optimization.runs.view" +OPTIMIZATION_RUNS_CREATE = "delivery.optimization.runs.create" +OPTIMIZATION_RUNS_MANAGE = "delivery.optimization.runs.manage" + ROUTING_VIEW = "delivery.routing.view" ROUTING_MANAGE = "delivery.routing.manage" + +# Phase 10.7 — Tracking & Proof of Delivery +TRACKING_SESSIONS_VIEW = "delivery.tracking.sessions.view" +TRACKING_SESSIONS_CREATE = "delivery.tracking.sessions.create" +TRACKING_SESSIONS_UPDATE = "delivery.tracking.sessions.update" +TRACKING_SESSIONS_MANAGE = "delivery.tracking.sessions.manage" + +TRACKING_POINTS_VIEW = "delivery.tracking.points.view" +TRACKING_POINTS_MANAGE = "delivery.tracking.points.manage" + +PROOF_OF_DELIVERY_VIEW = "delivery.proof_of_delivery.view" +PROOF_OF_DELIVERY_CREATE = "delivery.proof_of_delivery.create" +PROOF_OF_DELIVERY_UPDATE = "delivery.proof_of_delivery.update" +PROOF_OF_DELIVERY_MANAGE = "delivery.proof_of_delivery.manage" + +CUSTOMER_TRACKING_VIEW = "delivery.customer_tracking.view" +CUSTOMER_TRACKING_CREATE = "delivery.customer_tracking.create" +CUSTOMER_TRACKING_REVOKE = "delivery.customer_tracking.revoke" +CUSTOMER_TRACKING_MANAGE = "delivery.customer_tracking.manage" + TRACKING_VIEW = "delivery.tracking.view" TRACKING_MANAGE = "delivery.tracking.manage" + +# Phase 10.8 — Settlement +SETTLEMENTS_VIEW = "delivery.settlements.view" +SETTLEMENTS_CREATE = "delivery.settlements.create" +SETTLEMENTS_UPDATE = "delivery.settlements.update" +SETTLEMENTS_STATUS = "delivery.settlements.status" +SETTLEMENTS_LINES_VIEW = "delivery.settlements.lines.view" +SETTLEMENTS_LINES_MANAGE = "delivery.settlements.lines.manage" +SETTLEMENTS_MANAGE = "delivery.settlements.manage" + SETTLEMENT_VIEW = "delivery.settlement.view" SETTLEMENT_MANAGE = "delivery.settlement.manage" +# Legacy aliases +FLEET_VIEW = FLEETS_VIEW +FLEET_MANAGE = FLEETS_MANAGE + ALL_PERMISSIONS: list[str] = [ DELIVERY_VIEW, DELIVERY_MANAGE, @@ -118,16 +234,103 @@ ALL_PERMISSIONS: list[str] = [ DRIVERS_DOCUMENTS_VIEW, DRIVERS_DOCUMENTS_MANAGE, DRIVERS_MANAGE, - FLEET_VIEW, - FLEET_MANAGE, + FLEETS_VIEW, + FLEETS_CREATE, + FLEETS_UPDATE, + FLEETS_DELETE, + FLEETS_MANAGE, + VEHICLE_TYPES_VIEW, + VEHICLE_TYPES_CREATE, + VEHICLE_TYPES_UPDATE, + VEHICLE_TYPES_DELETE, + VEHICLE_TYPES_MANAGE, + VEHICLES_VIEW, + VEHICLES_CREATE, + VEHICLES_UPDATE, + VEHICLES_DELETE, + VEHICLES_ASSIGNMENTS_VIEW, + VEHICLES_ASSIGNMENTS_MANAGE, + VEHICLES_MANAGE, + AVAILABILITY_VIEW, + AVAILABILITY_MANAGE, + SHIFTS_VIEW, + SHIFTS_CREATE, + SHIFTS_UPDATE, + SHIFTS_DELETE, + SHIFTS_ASSIGNMENTS_VIEW, + SHIFTS_ASSIGNMENTS_MANAGE, + SHIFTS_MANAGE, + WORKING_ZONES_VIEW, + WORKING_ZONES_CREATE, + WORKING_ZONES_UPDATE, + WORKING_ZONES_DELETE, + WORKING_ZONES_MANAGE, + PRICING_RULES_VIEW, + PRICING_RULES_CREATE, + PRICING_RULES_UPDATE, + PRICING_RULES_DELETE, + PRICING_RULES_MANAGE, + CAPABILITIES_VIEW, + CAPABILITIES_CREATE, + CAPABILITIES_UPDATE, + CAPABILITIES_DELETE, + CAPABILITIES_MANAGE, + BUNDLES_VIEW, + BUNDLES_CREATE, + BUNDLES_UPDATE, + BUNDLES_DELETE, + BUNDLES_MANAGE, + DISPATCH_JOBS_VIEW, + DISPATCH_JOBS_CREATE, + DISPATCH_JOBS_UPDATE, + DISPATCH_JOBS_DELETE, + DISPATCH_JOBS_STATUS, + DISPATCH_JOBS_MANAGE, + DISPATCH_ASSIGNMENTS_VIEW, + DISPATCH_ASSIGNMENTS_CREATE, + DISPATCH_ASSIGNMENTS_UPDATE, + DISPATCH_ASSIGNMENTS_MANAGE, DISPATCH_VIEW, DISPATCH_MANAGE, + ROUTES_VIEW, + ROUTES_CREATE, + ROUTES_UPDATE, + ROUTES_DELETE, + ROUTES_STOPS_VIEW, + ROUTES_STOPS_MANAGE, + ROUTES_MANAGE, + OPTIMIZATION_RUNS_VIEW, + OPTIMIZATION_RUNS_CREATE, + OPTIMIZATION_RUNS_MANAGE, ROUTING_VIEW, ROUTING_MANAGE, + TRACKING_SESSIONS_VIEW, + TRACKING_SESSIONS_CREATE, + TRACKING_SESSIONS_UPDATE, + TRACKING_SESSIONS_MANAGE, + TRACKING_POINTS_VIEW, + TRACKING_POINTS_MANAGE, + PROOF_OF_DELIVERY_VIEW, + PROOF_OF_DELIVERY_CREATE, + PROOF_OF_DELIVERY_UPDATE, + PROOF_OF_DELIVERY_MANAGE, + CUSTOMER_TRACKING_VIEW, + CUSTOMER_TRACKING_CREATE, + CUSTOMER_TRACKING_REVOKE, + CUSTOMER_TRACKING_MANAGE, TRACKING_VIEW, TRACKING_MANAGE, + SETTLEMENTS_VIEW, + SETTLEMENTS_CREATE, + SETTLEMENTS_UPDATE, + SETTLEMENTS_STATUS, + SETTLEMENTS_LINES_VIEW, + SETTLEMENTS_LINES_MANAGE, + SETTLEMENTS_MANAGE, SETTLEMENT_VIEW, SETTLEMENT_MANAGE, + FLEET_VIEW, + FLEET_MANAGE, ] PERMISSION_PREFIXES: tuple[str, ...] = ( @@ -140,9 +343,23 @@ PERMISSION_PREFIXES: tuple[str, ...] = ( "delivery.settings.", "delivery.audit.", "delivery.drivers.", - "delivery.fleet.", + "delivery.fleets.", + "delivery.vehicle_types.", + "delivery.vehicles.", + "delivery.availability.", + "delivery.shifts.", + "delivery.working_zones.", + "delivery.pricing_rules.", + "delivery.capabilities.", + "delivery.bundles.", "delivery.dispatch.", - "delivery.routing.", + "delivery.routes.", + "delivery.optimization.", "delivery.tracking.", + "delivery.proof_of_delivery.", + "delivery.customer_tracking.", + "delivery.settlements.", + "delivery.fleet.", + "delivery.routing.", "delivery.settlement.", ) diff --git a/backend/services/delivery/app/policies/dispatch.py b/backend/services/delivery/app/policies/dispatch.py new file mode 100644 index 0000000..3c25a3a --- /dev/null +++ b/backend/services/delivery/app/policies/dispatch.py @@ -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 diff --git a/backend/services/delivery/app/policies/settlement.py b/backend/services/delivery/app/policies/settlement.py new file mode 100644 index 0000000..a5e8090 --- /dev/null +++ b/backend/services/delivery/app/policies/settlement.py @@ -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 diff --git a/backend/services/delivery/app/queries/availability.py b/backend/services/delivery/app/queries/availability.py new file mode 100644 index 0000000..a1f0fff --- /dev/null +++ b/backend/services/delivery/app/queries/availability.py @@ -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) diff --git a/backend/services/delivery/app/queries/dispatch.py b/backend/services/delivery/app/queries/dispatch.py new file mode 100644 index 0000000..9aa7281 --- /dev/null +++ b/backend/services/delivery/app/queries/dispatch.py @@ -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 + ) diff --git a/backend/services/delivery/app/queries/fleet.py b/backend/services/delivery/app/queries/fleet.py new file mode 100644 index 0000000..71f1815 --- /dev/null +++ b/backend/services/delivery/app/queries/fleet.py @@ -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) diff --git a/backend/services/delivery/app/queries/pricing.py b/backend/services/delivery/app/queries/pricing.py new file mode 100644 index 0000000..6092fb3 --- /dev/null +++ b/backend/services/delivery/app/queries/pricing.py @@ -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) diff --git a/backend/services/delivery/app/queries/routing.py b/backend/services/delivery/app/queries/routing.py new file mode 100644 index 0000000..4c153d4 --- /dev/null +++ b/backend/services/delivery/app/queries/routing.py @@ -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 + ) diff --git a/backend/services/delivery/app/queries/settlement.py b/backend/services/delivery/app/queries/settlement.py new file mode 100644 index 0000000..8e346bf --- /dev/null +++ b/backend/services/delivery/app/queries/settlement.py @@ -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) diff --git a/backend/services/delivery/app/queries/tracking.py b/backend/services/delivery/app/queries/tracking.py new file mode 100644 index 0000000..70a35d9 --- /dev/null +++ b/backend/services/delivery/app/queries/tracking.py @@ -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) diff --git a/backend/services/delivery/app/repositories/availability.py b/backend/services/delivery/app/repositories/availability.py new file mode 100644 index 0000000..583fb64 --- /dev/null +++ b/backend/services/delivery/app/repositories/availability.py @@ -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()) diff --git a/backend/services/delivery/app/repositories/dispatch.py b/backend/services/delivery/app/repositories/dispatch.py new file mode 100644 index 0000000..5b3883f --- /dev/null +++ b/backend/services/delivery/app/repositories/dispatch.py @@ -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() diff --git a/backend/services/delivery/app/repositories/fleet.py b/backend/services/delivery/app/repositories/fleet.py new file mode 100644 index 0000000..fc0a6e9 --- /dev/null +++ b/backend/services/delivery/app/repositories/fleet.py @@ -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() diff --git a/backend/services/delivery/app/repositories/pricing.py b/backend/services/delivery/app/repositories/pricing.py new file mode 100644 index 0000000..8d32e8f --- /dev/null +++ b/backend/services/delivery/app/repositories/pricing.py @@ -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()) diff --git a/backend/services/delivery/app/repositories/routing.py b/backend/services/delivery/app/repositories/routing.py new file mode 100644 index 0000000..5113f73 --- /dev/null +++ b/backend/services/delivery/app/repositories/routing.py @@ -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()) diff --git a/backend/services/delivery/app/repositories/settlement.py b/backend/services/delivery/app/repositories/settlement.py new file mode 100644 index 0000000..fcb1d9e --- /dev/null +++ b/backend/services/delivery/app/repositories/settlement.py @@ -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() diff --git a/backend/services/delivery/app/repositories/tracking.py b/backend/services/delivery/app/repositories/tracking.py new file mode 100644 index 0000000..2538efe --- /dev/null +++ b/backend/services/delivery/app/repositories/tracking.py @@ -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()) diff --git a/backend/services/delivery/app/schemas/availability.py b/backend/services/delivery/app/schemas/availability.py new file mode 100644 index 0000000..ccf1f62 --- /dev/null +++ b/backend/services/delivery/app/schemas/availability.py @@ -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 diff --git a/backend/services/delivery/app/schemas/dispatch.py b/backend/services/delivery/app/schemas/dispatch.py new file mode 100644 index 0000000..e3e647b --- /dev/null +++ b/backend/services/delivery/app/schemas/dispatch.py @@ -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 diff --git a/backend/services/delivery/app/schemas/fleet.py b/backend/services/delivery/app/schemas/fleet.py new file mode 100644 index 0000000..fb35ab8 --- /dev/null +++ b/backend/services/delivery/app/schemas/fleet.py @@ -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 diff --git a/backend/services/delivery/app/schemas/pricing.py b/backend/services/delivery/app/schemas/pricing.py new file mode 100644 index 0000000..ac7b747 --- /dev/null +++ b/backend/services/delivery/app/schemas/pricing.py @@ -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 diff --git a/backend/services/delivery/app/schemas/routing.py b/backend/services/delivery/app/schemas/routing.py new file mode 100644 index 0000000..f78e0f9 --- /dev/null +++ b/backend/services/delivery/app/schemas/routing.py @@ -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 diff --git a/backend/services/delivery/app/schemas/settlement.py b/backend/services/delivery/app/schemas/settlement.py new file mode 100644 index 0000000..c55f019 --- /dev/null +++ b/backend/services/delivery/app/schemas/settlement.py @@ -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 diff --git a/backend/services/delivery/app/schemas/tracking.py b/backend/services/delivery/app/schemas/tracking.py new file mode 100644 index 0000000..5bbcb04 --- /dev/null +++ b/backend/services/delivery/app/schemas/tracking.py @@ -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 diff --git a/backend/services/delivery/app/services/_helpers.py b/backend/services/delivery/app/services/_helpers.py new file mode 100644 index 0000000..8f16e50 --- /dev/null +++ b/backend/services/delivery/app/services/_helpers.py @@ -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 diff --git a/backend/services/delivery/app/services/availability.py b/backend/services/delivery/app/services/availability.py new file mode 100644 index 0000000..010a68f --- /dev/null +++ b/backend/services/delivery/app/services/availability.py @@ -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 diff --git a/backend/services/delivery/app/services/dispatch.py b/backend/services/delivery/app/services/dispatch.py new file mode 100644 index 0000000..73bd0c3 --- /dev/null +++ b/backend/services/delivery/app/services/dispatch.py @@ -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 diff --git a/backend/services/delivery/app/services/fleet.py b/backend/services/delivery/app/services/fleet.py new file mode 100644 index 0000000..68f2863 --- /dev/null +++ b/backend/services/delivery/app/services/fleet.py @@ -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 diff --git a/backend/services/delivery/app/services/pricing.py b/backend/services/delivery/app/services/pricing.py new file mode 100644 index 0000000..1e9125b --- /dev/null +++ b/backend/services/delivery/app/services/pricing.py @@ -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 diff --git a/backend/services/delivery/app/services/routing.py b/backend/services/delivery/app/services/routing.py new file mode 100644 index 0000000..d25e30d --- /dev/null +++ b/backend/services/delivery/app/services/routing.py @@ -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) diff --git a/backend/services/delivery/app/services/settlement.py b/backend/services/delivery/app/services/settlement.py new file mode 100644 index 0000000..1481684 --- /dev/null +++ b/backend/services/delivery/app/services/settlement.py @@ -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)) diff --git a/backend/services/delivery/app/services/tracking.py b/backend/services/delivery/app/services/tracking.py new file mode 100644 index 0000000..43b3a9a --- /dev/null +++ b/backend/services/delivery/app/services/tracking.py @@ -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 diff --git a/backend/services/delivery/app/specifications/availability.py b/backend/services/delivery/app/specifications/availability.py new file mode 100644 index 0000000..2a870d6 --- /dev/null +++ b/backend/services/delivery/app/specifications/availability.py @@ -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) diff --git a/backend/services/delivery/app/specifications/dispatch.py b/backend/services/delivery/app/specifications/dispatch.py new file mode 100644 index 0000000..9dfee92 --- /dev/null +++ b/backend/services/delivery/app/specifications/dispatch.py @@ -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) diff --git a/backend/services/delivery/app/specifications/fleet.py b/backend/services/delivery/app/specifications/fleet.py new file mode 100644 index 0000000..c3bf21d --- /dev/null +++ b/backend/services/delivery/app/specifications/fleet.py @@ -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) diff --git a/backend/services/delivery/app/specifications/pricing.py b/backend/services/delivery/app/specifications/pricing.py new file mode 100644 index 0000000..940168d --- /dev/null +++ b/backend/services/delivery/app/specifications/pricing.py @@ -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) diff --git a/backend/services/delivery/app/specifications/routing.py b/backend/services/delivery/app/specifications/routing.py new file mode 100644 index 0000000..3f2a370 --- /dev/null +++ b/backend/services/delivery/app/specifications/routing.py @@ -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) diff --git a/backend/services/delivery/app/specifications/settlement.py b/backend/services/delivery/app/specifications/settlement.py new file mode 100644 index 0000000..9295bd8 --- /dev/null +++ b/backend/services/delivery/app/specifications/settlement.py @@ -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) diff --git a/backend/services/delivery/app/specifications/tracking.py b/backend/services/delivery/app/specifications/tracking.py new file mode 100644 index 0000000..4e7123b --- /dev/null +++ b/backend/services/delivery/app/specifications/tracking.py @@ -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) diff --git a/backend/services/delivery/app/tests/test_api.py b/backend/services/delivery/app/tests/test_api.py index 28a41ee..96364e4 100644 --- a/backend/services/delivery/app/tests/test_api.py +++ b/backend/services/delivery/app/tests/test_api.py @@ -15,16 +15,18 @@ async def test_health_capabilities_metrics(client): caps = await client.get("/capabilities") assert caps.status_code == 200 c = caps.json() - assert c["phase"] == "10.1" + assert c["phase"] == "10.8" assert c["commercial_product"] == "Torbat Driver" assert c["features"]["foundation"] is True assert c["features"]["drivers"] is True - assert c["features"]["dispatch_engine"] is False + assert c["features"]["dispatch_engine"] is True + assert c["features"]["settlement"] is True assert c["independence"]["integration_mode"] == "api_and_events_only" + assert c["independence"]["no_journal_entries_in_delivery_db"] is True metrics = await client.get("/metrics") assert metrics.status_code == 200 - assert metrics.json()["phase"] == "10.1" + assert metrics.json()["phase"] == "10.8" async def test_foundation_flow_and_events(client): diff --git a/backend/services/delivery/app/tests/test_architecture.py b/backend/services/delivery/app/tests/test_architecture.py index 5eedc9d..cfe5724 100644 --- a/backend/services/delivery/app/tests/test_architecture.py +++ b/backend/services/delivery/app/tests/test_architecture.py @@ -32,6 +32,36 @@ FOUNDATION_MODELS = [ models.DeliveryAuditLog, ] +PHASE_TABLES = { + "drivers", + "driver_credentials", + "driver_documents", + "driver_lifecycle_events", + "fleets", + "vehicle_types", + "vehicles", + "vehicle_assignments", + "driver_availabilities", + "shifts", + "shift_assignments", + "working_zones", + "pricing_rules", + "capability_definitions", + "capability_bundles", + "dispatch_jobs", + "job_assignments", + "route_plans", + "route_stops", + "optimization_runs", + "tracking_sessions", + "tracking_points", + "proof_of_delivery", + "customer_tracking_tokens", + "settlement_intents", + "settlement_lines", + "outbox_events", +} + def test_all_models_have_tenant_id(): from app.core.database import Base @@ -52,59 +82,55 @@ def test_foundation_aggregates_are_independent(): assert not model.__mapper__.relationships -def test_no_dispatch_engine_tables(): +def test_phase_tables_registered(): from app.core.database import Base import app.models # noqa: F401 names = set(Base.metadata.tables.keys()) - assert "drivers" in names - assert "driver_credentials" in names - assert "driver_documents" in names - assert "driver_lifecycle_events" in names - assert "outbox_events" in names - forbidden = { - "dispatch_jobs", - "routes", - "tracking_sessions", - "vehicles", - "fleets", - "proof_of_delivery", - } - assert forbidden.isdisjoint(names) + assert PHASE_TABLES.issubset(names) + assert "journal_entries" not in names def test_permissions_defined(): from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES assert "delivery.view" in ALL_PERMISSIONS - assert "delivery.organizations.create" in ALL_PERMISSIONS - assert "delivery.external_providers.manage" in ALL_PERMISSIONS - assert "delivery.routing_engines.view" in ALL_PERMISSIONS - assert "delivery.audit.view" in ALL_PERMISSIONS assert "delivery.drivers.create" in ALL_PERMISSIONS - assert "delivery.drivers.activate" in ALL_PERMISSIONS - assert "delivery.drivers.credentials.manage" in ALL_PERMISSIONS - assert "delivery.dispatch.manage" in ALL_PERMISSIONS + assert "delivery.fleets.create" in ALL_PERMISSIONS + assert "delivery.vehicle_types.view" in ALL_PERMISSIONS + assert "delivery.vehicles.assignments.manage" in ALL_PERMISSIONS + assert "delivery.availability.manage" in ALL_PERMISSIONS + assert "delivery.shifts.create" in ALL_PERMISSIONS + assert "delivery.working_zones.view" in ALL_PERMISSIONS + assert "delivery.pricing_rules.create" in ALL_PERMISSIONS + assert "delivery.capabilities.view" in ALL_PERMISSIONS + assert "delivery.bundles.manage" in ALL_PERMISSIONS + assert "delivery.dispatch.jobs.status" in ALL_PERMISSIONS + assert "delivery.routes.stops.manage" in ALL_PERMISSIONS + assert "delivery.optimization.runs.create" in ALL_PERMISSIONS + assert "delivery.tracking.sessions.view" in ALL_PERMISSIONS + assert "delivery.proof_of_delivery.create" in ALL_PERMISSIONS + assert "delivery.customer_tracking.revoke" in ALL_PERMISSIONS + assert "delivery.settlements.status" in ALL_PERMISSIONS + assert len(ALL_PERMISSIONS) >= 100 for prefix in PERMISSION_PREFIXES: - assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS) + assert any( + p.startswith(prefix.rstrip(".")) or p.startswith(prefix) + for p in ALL_PERMISSIONS + ) def test_events_defined(): from app.events.types import DeliveryEventType - assert DeliveryEventType.ORGANIZATION_CREATED.value == "delivery.organization.created" - assert DeliveryEventType.HUB_CREATED.value == "delivery.hub.created" - assert ( - DeliveryEventType.EXTERNAL_PROVIDER_REGISTERED.value - == "delivery.external_provider.registered" - ) - assert ( - DeliveryEventType.ROUTING_ENGINE_REGISTERED.value - == "delivery.routing_engine.registered" - ) assert DeliveryEventType.DRIVER_CREATED.value == "delivery.driver.created" - assert DeliveryEventType.DRIVER_ACTIVATED.value == "delivery.driver.activated" - assert DeliveryEventType.DRIVER_STATUS_CHANGED.value == "delivery.driver.status_changed" + assert DeliveryEventType.FLEET_CREATED.value == "delivery.fleet.created" + assert DeliveryEventType.SHIFT_CREATED.value == "delivery.shift.created" + assert DeliveryEventType.PRICING_RULE_CREATED.value == "delivery.pricing_rule.created" + assert DeliveryEventType.DISPATCH_JOB_CREATED.value == "delivery.dispatch_job.created" + assert DeliveryEventType.ROUTE_PLAN_CREATED.value == "delivery.route_plan.created" + assert DeliveryEventType.TRACKING_SESSION_STARTED.value == "delivery.tracking_session.started" + assert DeliveryEventType.SETTLEMENT_INTENT_CREATED.value == "delivery.settlement_intent.created" def test_platform_provider_contracts_exist(): @@ -122,15 +148,15 @@ def test_platform_provider_contracts_exist(): ) assert AccountingProvider is not None - assert CommunicationProvider is not None assert RoutingEngineProvider is not None assert FleetProvider is not None - assert LoyaltyProvider is not None - assert CRMProvider is not None - assert NotificationProvider is not None assert StorageProvider is not None - assert AIProvider is not None assert IdentityProvider is not None + assert CRMProvider is not None + assert CommunicationProvider is not None + assert NotificationProvider is not None + assert LoyaltyProvider is not None + assert AIProvider is not None def test_no_forbidden_service_imports(): @@ -159,8 +185,10 @@ def test_api_ownership_is_delivery_only(): prefixes = {getattr(r, "path", "") for r in api_router.routes} joined = " ".join(sorted(prefixes)) assert "/organizations" in joined or any("/organizations" in p for p in prefixes) - assert "/hubs" in joined or any("/hubs" in p for p in prefixes) assert "/drivers" in joined or any("/drivers" in p for p in prefixes) + assert "/fleets" in joined or any("/fleets" in p for p in prefixes) + assert "/dispatch/jobs" in joined or any("/dispatch/jobs" in p for p in prefixes) + assert "/settlements" in joined or any("/settlements" in p for p in prefixes) forbidden = ("accounting", "crm", "loyalty", "notification", "automation", "sports") for name in forbidden: assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name diff --git a/backend/services/delivery/app/tests/test_migration.py b/backend/services/delivery/app/tests/test_migration.py index f32a1f0..a94b848 100644 --- a/backend/services/delivery/app/tests/test_migration.py +++ b/backend/services/delivery/app/tests/test_migration.py @@ -20,17 +20,51 @@ EXPECTED_TABLES = { "driver_documents", "driver_lifecycle_events", "outbox_events", + "fleets", + "vehicle_types", + "vehicles", + "vehicle_assignments", + "driver_availabilities", + "shifts", + "shift_assignments", + "working_zones", + "pricing_rules", + "capability_definitions", + "capability_bundles", + "dispatch_jobs", + "job_assignments", + "route_plans", + "route_stops", + "optimization_runs", + "tracking_sessions", + "tracking_points", + "proof_of_delivery", + "customer_tracking_tokens", + "settlement_intents", + "settlement_lines", } def test_alembic_revision_exists(): versions = Path(__file__).resolve().parents[2] / "alembic" / "versions" - files = list(versions.glob("0001_initial*.py")) - assert files, "missing 0001_initial migration" - phase101 = list(versions.glob("0002_phase_101_drivers*.py")) - assert phase101, "missing 0002_phase_101_drivers migration" + assert list(versions.glob("0001_initial*.py")), "missing 0001_initial migration" + assert list(versions.glob("0002_phase_101_drivers*.py")), "missing 0002 migration" + assert list(versions.glob("0003_phase_102_fleet*.py")), "missing 0003 migration" + assert list(versions.glob("0004_phase_103_availability*.py")), "missing 0004 migration" + assert list(versions.glob("0005_phase_104_pricing*.py")), "missing 0005 migration" + assert list(versions.glob("0006_phase_105_dispatch*.py")), "missing 0006 migration" + assert list(versions.glob("0007_phase_106_routing*.py")), "missing 0007 migration" + assert list(versions.glob("0008_phase_107_108_tracking_settlement*.py")), ( + "missing 0008 migration" + ) -def test_metadata_has_foundation_tables(): +def test_metadata_has_all_tables(): names = set(Base.metadata.tables.keys()) assert EXPECTED_TABLES.issubset(names) + + +def test_no_journal_entry_table(): + names = set(Base.metadata.tables.keys()) + assert "journal_entries" not in names + assert "JournalEntry".lower() not in {n.lower() for n in names} diff --git a/backend/services/delivery/app/tests/test_phase_102.py b/backend/services/delivery/app/tests/test_phase_102.py new file mode 100644 index 0000000..3f740d2 --- /dev/null +++ b/backend/services/delivery/app/tests/test_phase_102.py @@ -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 diff --git a/backend/services/delivery/app/tests/test_phase_103.py b/backend/services/delivery/app/tests/test_phase_103.py new file mode 100644 index 0000000..8dc1371 --- /dev/null +++ b/backend/services/delivery/app/tests/test_phase_103.py @@ -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 diff --git a/backend/services/delivery/app/tests/test_phase_104.py b/backend/services/delivery/app/tests/test_phase_104.py new file mode 100644 index 0000000..a8f7c1f --- /dev/null +++ b/backend/services/delivery/app/tests/test_phase_104.py @@ -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 diff --git a/backend/services/delivery/app/tests/test_phase_105.py b/backend/services/delivery/app/tests/test_phase_105.py new file mode 100644 index 0000000..566c63e --- /dev/null +++ b/backend/services/delivery/app/tests/test_phase_105.py @@ -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 diff --git a/backend/services/delivery/app/tests/test_phase_106.py b/backend/services/delivery/app/tests/test_phase_106.py new file mode 100644 index 0000000..8670f2a --- /dev/null +++ b/backend/services/delivery/app/tests/test_phase_106.py @@ -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 diff --git a/backend/services/delivery/app/tests/test_phase_107.py b/backend/services/delivery/app/tests/test_phase_107.py new file mode 100644 index 0000000..b6382e3 --- /dev/null +++ b/backend/services/delivery/app/tests/test_phase_107.py @@ -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 diff --git a/backend/services/delivery/app/tests/test_phase_108.py b/backend/services/delivery/app/tests/test_phase_108.py new file mode 100644 index 0000000..cacff41 --- /dev/null +++ b/backend/services/delivery/app/tests/test_phase_108.py @@ -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 diff --git a/backend/services/delivery/app/validators/availability.py b/backend/services/delivery/app/validators/availability.py new file mode 100644 index 0000000..2c9674a --- /dev/null +++ b/backend/services/delivery/app/validators/availability.py @@ -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") diff --git a/backend/services/delivery/app/validators/dispatch.py b/backend/services/delivery/app/validators/dispatch.py new file mode 100644 index 0000000..0d28094 --- /dev/null +++ b/backend/services/delivery/app/validators/dispatch.py @@ -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 diff --git a/backend/services/delivery/app/validators/fleet.py b/backend/services/delivery/app/validators/fleet.py new file mode 100644 index 0000000..6a1d618 --- /dev/null +++ b/backend/services/delivery/app/validators/fleet.py @@ -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 diff --git a/backend/services/delivery/app/validators/pricing.py b/backend/services/delivery/app/validators/pricing.py new file mode 100644 index 0000000..6b113f4 --- /dev/null +++ b/backend/services/delivery/app/validators/pricing.py @@ -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) diff --git a/backend/services/delivery/app/validators/routing.py b/backend/services/delivery/app/validators/routing.py new file mode 100644 index 0000000..136958a --- /dev/null +++ b/backend/services/delivery/app/validators/routing.py @@ -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 diff --git a/backend/services/delivery/app/validators/settlement.py b/backend/services/delivery/app/validators/settlement.py new file mode 100644 index 0000000..50c0d33 --- /dev/null +++ b/backend/services/delivery/app/validators/settlement.py @@ -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 diff --git a/backend/services/delivery/app/validators/tracking.py b/backend/services/delivery/app/validators/tracking.py new file mode 100644 index 0000000..3f8a453 --- /dev/null +++ b/backend/services/delivery/app/validators/tracking.py @@ -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", + ) diff --git a/backend/services/delivery/scripts/build_all_phases.py b/backend/services/delivery/scripts/build_all_phases.py new file mode 100644 index 0000000..3aebae4 --- /dev/null +++ b/backend/services/delivery/scripts/build_all_phases.py @@ -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() diff --git a/backend/services/delivery/scripts/gen_phases_102_108.py b/backend/services/delivery/scripts/gen_phases_102_108.py new file mode 100644 index 0000000..2440f45 --- /dev/null +++ b/backend/services/delivery/scripts/gen_phases_102_108.py @@ -0,0 +1,126 @@ +"""One-shot generator for delivery phases 10.2-10.8 boilerplate.""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / "app" + + +def w(rel: str, content: str) -> None: + path = ROOT / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(f"wrote {rel}") + + +# --- Events --- +events = '''"""Delivery event type contracts (publish-only).""" +from __future__ import annotations + +import enum + + +class DeliveryEventType(str, enum.Enum): + ORGANIZATION_CREATED = "delivery.organization.created" + ORGANIZATION_UPDATED = "delivery.organization.updated" + HUB_CREATED = "delivery.hub.created" + HUB_UPDATED = "delivery.hub.updated" + EXTERNAL_PROVIDER_REGISTERED = "delivery.external_provider.registered" + EXTERNAL_PROVIDER_UPDATED = "delivery.external_provider.updated" + ROUTING_ENGINE_REGISTERED = "delivery.routing_engine.registered" + ROUTING_ENGINE_UPDATED = "delivery.routing_engine.updated" + CONFIGURATION_CREATED = "delivery.configuration.created" + CONFIGURATION_UPDATED = "delivery.configuration.updated" + SETTING_UPSERTED = "delivery.setting.upserted" + + # Phase 10.1 — Driver Management + DRIVER_CREATED = "delivery.driver.created" + DRIVER_UPDATED = "delivery.driver.updated" + DRIVER_ACTIVATED = "delivery.driver.activated" + DRIVER_SUSPENDED = "delivery.driver.suspended" + DRIVER_RESUMED = "delivery.driver.resumed" + DRIVER_DEACTIVATED = "delivery.driver.deactivated" + DRIVER_BLOCKED = "delivery.driver.blocked" + DRIVER_UNBLOCKED = "delivery.driver.unblocked" + DRIVER_ARCHIVED = "delivery.driver.archived" + DRIVER_DELETED = "delivery.driver.deleted" + DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added" + DRIVER_DOCUMENT_ATTACHED = "delivery.driver.document_attached" + DRIVER_STATUS_CHANGED = "delivery.driver.status_changed" + + # 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" +''' +w("events/types.py", events) + +if __name__ == "__main__": + print("done partial") diff --git a/backend/services/delivery/scripts/generate_phases_102_108.py b/backend/services/delivery/scripts/generate_phases_102_108.py new file mode 100644 index 0000000..4a6906b --- /dev/null +++ b/backend/services/delivery/scripts/generate_phases_102_108.py @@ -0,0 +1,209 @@ +"""Generate delivery phases 10.2-10.8 implementation files.""" +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def w(rel: str, content: str) -> None: + path = ROOT / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content.rstrip() + "\n", encoding="utf-8") + print(f"wrote {rel}") + + +# --------------------------------------------------------------------------- +# schemas/fleet.py +# --------------------------------------------------------------------------- +w( + "app/schemas/fleet.py", + '''"""Fleet & vehicle DTOs — Phase 10.2.""" +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 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 +''', +) + +print("Phase 10.2 schemas done — run full generator for remaining files") diff --git a/docs/ai-framework/phase-manifest.yaml b/docs/ai-framework/phase-manifest.yaml index 3489d27..523de7e 100644 --- a/docs/ai-framework/phase-manifest.yaml +++ b/docs/ai-framework/phase-manifest.yaml @@ -13,7 +13,7 @@ updated: "2026-07-27" # Hospitality Phase 12.3 complete; 12.4 POS Lite next for Hospitality track. # Experience 11.2 Component Library complete; next experience-11.3 was completed — next experience-11.4 Template System. # Experience 11.3 Theme & Layout complete; 11.4 Template System complete; next experience-11.5 Localization, RTL/LTR & Media. -# Delivery Platform registration complete; Phase 10.1 Driver Management complete; next delivery-10.2. +# Delivery Platform registration complete; Phase 10.8 Settlement complete; next delivery-10.9. # Loyalty Phase 7.6 complete; 7.7 Gift Card Platform next for Loyalty track. phases: @@ -824,7 +824,7 @@ phases: name: Fleet & Vehicle Types area: Delivery description: Fleets, vehicle types catalog, vehicles, assignments shells, permissions, events, APIs. - status: planned + status: complete dependencies: - delivery-10.1 required_previous_phase: delivery-10.1 @@ -841,7 +841,7 @@ phases: name: Availability, Shifts & Working Zones area: Delivery description: Driver availability, shift management, working zones, readiness rules. - status: planned + status: complete dependencies: - delivery-10.2 required_previous_phase: delivery-10.2 @@ -858,7 +858,7 @@ phases: name: Pricing, Capabilities & Bundles area: Delivery description: Delivery pricing shells, capability flags, capability bundles. - status: planned + status: complete dependencies: - delivery-10.3 required_previous_phase: delivery-10.3 @@ -875,7 +875,7 @@ phases: name: Dispatch Engine area: Delivery description: Dispatch engine, job assignment, dispatcher workflows (API), multi-vertical job refs. - status: planned + status: complete dependencies: - delivery-10.4 required_previous_phase: delivery-10.4 @@ -893,7 +893,7 @@ phases: name: Routing & Optimization area: Delivery description: Routing plans, multi pickup, multi drop, optimization strategies, future routing engine adapters. - status: planned + status: complete dependencies: - delivery-10.5 required_previous_phase: delivery-10.5 @@ -910,7 +910,7 @@ phases: name: Tracking & Proof of Delivery area: Delivery description: Live tracking, journey timeline, proof of delivery, customer tracking APIs. - status: planned + status: complete dependencies: - delivery-10.6 required_previous_phase: delivery-10.6 @@ -927,7 +927,7 @@ phases: name: Settlement area: Delivery description: Driver/merchant settlement intents; post to Accounting via API/Events only — no journal ownership. - status: planned + status: complete dependencies: - delivery-10.7 - accounting-5.11 @@ -1394,6 +1394,31 @@ phases: - No full project scan required when file exists - Framework handover generated; STOP + - id: platform-project-status-v2 + name: Project Status Execution Dashboard v2 + area: docs/ai-framework + description: > + Upgrades project-status to single execution dashboard with deployment_readiness, + execution_priority, critical_path, depends_on/required_by, completion percents, + resume_rules, automatic_update_rules. Deprecates next_recommended_phase. + status: complete + dependencies: + - platform-project-status + required_documents: + - docs/project-status.yaml + - docs/project-status.md + - docs/phase-handover/phase-af-project-status-v2.md + - docs/ai-framework/runtime-read-policy.md + - docs/ai-framework/development-loop.md + - docs/ai-framework/service-snapshot-policy.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/scripts/validate-project-status.mjs + completion_criteria: + - schema_version 2 with required dashboard sections + - next_recommended_phase deprecated and not active + - validate-project-status.mjs passes + - Framework docs updated; STOP — no business code + # --- Hospitality (Phase 12.0+) — Torbat Food --- - id: hospitality-12.0 name: Hospitality Platform Foundation diff --git a/docs/ai-framework/project-index.yaml b/docs/ai-framework/project-index.yaml index a737f95..b2735bf 100644 --- a/docs/ai-framework/project-index.yaml +++ b/docs/ai-framework/project-index.yaml @@ -655,3 +655,10 @@ framework_workstreams: latest_handover: docs/phase-handover/phase-af-project-status.md project_status: docs/project-status.yaml snapshot_file: null + - workstream_id: platform-project-status-v2 + description: Project Status Execution Dashboard v2 (critical_path, priorities, deployment_readiness) + latest_handover: docs/phase-handover/phase-af-project-status-v2.md + project_status: docs/project-status.yaml + framework_snapshot: docs/service-snapshots/framework-project-status.yaml + snapshot_file: null + validation: docs/ai-framework/scripts/validate-project-status.mjs diff --git a/docs/delivery-phase-10-8.md b/docs/delivery-phase-10-8.md new file mode 100644 index 0000000..3a300d6 --- /dev/null +++ b/docs/delivery-phase-10-8.md @@ -0,0 +1,50 @@ +# Phase 10.8 — Settlement + +| Field | Value | +| --- | --- | +| Identifier | `delivery-10.8` | +| Status | Complete | +| Module | delivery | +| Service | `delivery-service` | +| Version | `0.10.8.0` | +| Database | `delivery_db` | +| API Port | 8007 | +| Depends On | Phase 10.7, Accounting 5.11 (contracts) | +| ADR(s) | ADR-010, ADR-015, ADR-006 | +| Commercial Product | Torbat Driver | + +## Goal + +Deliver driver/merchant **settlement intents** in Delivery with line items and lifecycle, posting settlement references to Accounting via `AccountingProvider` protocol only — **no journal ownership** in `delivery_db`. + +## Scope Delivered + +- Aggregates: `SettlementIntent`, `SettlementLine` +- Lifecycle: draft → submitted → settled / failed +- `AccountingProvider.create_settlement_ref` on settle (`acct://` ref prefix) +- Permissions `delivery.settlement.*` +- Events `delivery.settlement_intent.*`, `delivery.settlement_line.added` +- APIs: `/api/v1/settlements`, lines, submit, settle +- Alembic `0008_phase_107_108_tracking_settlement` (shared with 10.7) +- Tests: `test_phase_108.py` — proves no journal tables in delivery_db + +## Out of Scope + +- Merchant connector (10.9) +- JournalEntry / Posting Engine (Accounting owns) +- Communication notifications on settlement (10.9) + +## Boundary Compliance + +| Delivery owns | Delivery does not own | +| --- | --- | +| Settlement intent + line metadata | Accounting journals | +| External settlement ref after settle | Cross-DB queries | +| Outbox `delivery.settlement_intent.*` | Payment PSP settlement | + +## Related Documents + +- [Phase Handover 10.8](phase-handover/phase-10-8.md) +- [Service Snapshot](service-snapshots/delivery.yaml) +- [Delivery Roadmap](delivery-roadmap.md) +- [ADR-010](architecture/adr/ADR-010.md) diff --git a/docs/delivery-roadmap.md b/docs/delivery-roadmap.md index 559f171..a5c3b2b 100644 --- a/docs/delivery-roadmap.md +++ b/docs/delivery-roadmap.md @@ -11,7 +11,7 @@ | Permission Prefix | `delivery.*` | | API Port (planned) | 8007 | | Phases | 10.0 – 10.10 | -| Status | Phase 10.1 Driver Management complete; next 10.2 Fleet & Vehicle Types | +| Status | Phase 10.8 Settlement complete; next 10.9 Merchant Connector & App Surfaces | ## Vision @@ -114,13 +114,13 @@ Forbidden: cross-DB queries, embedding SMS providers inside Delivery, creating J | Reg | `delivery-reg` | Platform Registration | Complete | | 10.0 | `delivery-10.0` | Foundation | Complete | | 10.1 | `delivery-10.1` | Driver Management | Complete | -| 10.2 | `delivery-10.2` | Fleet & Vehicle Types | Planned | -| 10.3 | `delivery-10.3` | Availability, Shifts & Working Zones | Planned | -| 10.4 | `delivery-10.4` | Pricing, Capabilities & Bundles | Planned | -| 10.5 | `delivery-10.5` | Dispatch Engine | Planned | -| 10.6 | `delivery-10.6` | Routing & Optimization | Planned | -| 10.7 | `delivery-10.7` | Tracking & Proof of Delivery | Planned | -| 10.8 | `delivery-10.8` | Settlement | Planned | +| 10.2 | `delivery-10.2` | Fleet & Vehicle Types | Complete | +| 10.3 | `delivery-10.3` | Availability, Shifts & Working Zones | Complete | +| 10.4 | `delivery-10.4` | Pricing, Capabilities & Bundles | Complete | +| 10.5 | `delivery-10.5` | Dispatch Engine | Complete | +| 10.6 | `delivery-10.6` | Routing & Optimization | Complete | +| 10.7 | `delivery-10.7` | Tracking & Proof of Delivery | Complete | +| 10.8 | `delivery-10.8` | Settlement | Complete | | 10.9 | `delivery-10.9` | Merchant Connector & App Surfaces | Planned | | 10.10 | `delivery-10.10` | Analytics, AI Ready & Enterprise Validation | Planned | diff --git a/docs/module-registry.md b/docs/module-registry.md index 7188b0a..0b54aaf 100644 --- a/docs/module-registry.md +++ b/docs/module-registry.md @@ -352,9 +352,9 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | Field | Value | | --- | --- | | Name | Delivery & Fleet Platform (Torbat Driver) | -| Description | Independent logistics platform (Torbat Driver): foundation orgs/hubs/config/providers/routing-engine shells; drivers active; fleet/dispatch in later phases | +| Description | Independent logistics platform (Torbat Driver): foundation through settlement (10.0–10.8); merchant connector and analytics in 10.9–10.10 | | Owner | Platform | -| Status | Active (Phase 10.1 drivers) | +| Status | Active (Phase 10.8 Settlement) | | Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication / verticals via API/Events when phases require them | | Internal Dependencies | Modules owned by this service only | | External Dependencies | Routing engines and fleet providers (adapters); notifications via Communication; Storage file refs for driver docs | @@ -362,16 +362,16 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | Database | `delivery_db` | | API Prefix | `/api/v1` (service on port 8007); `/health`, `/capabilities`, `/metrics` | | Permission Prefix | `delivery.*` | -| Events | `delivery.organization.*`, `delivery.hub.*`, `delivery.external_provider.*`, `delivery.routing_engine.*`, `delivery.configuration.*`, `delivery.setting.*`, `delivery.driver.*` (+ planned fleet/dispatch/...) | +| Events | `delivery.organization.*`, `delivery.hub.*`, `delivery.driver.*`, `delivery.fleet.*`, `delivery.vehicle.*`, `delivery.dispatch_job.*`, `delivery.route_plan.*`, `delivery.tracking_session.*`, `delivery.settlement_intent.*` (+ more) | | Event Producers | Delivery | | Event Consumers | Accounting, Communication, CRM, Loyalty, Restaurant, Marketplace, Clinic, Sports Center, Analytics (future) | | Provider Dependencies | External routing/fleet provider interfaces (planned); Storage refs for documents | | AI Dependencies | Optional via contracts only (Phase 10.10) | -| Documentation | [delivery-phase-10-1.md](delivery-phase-10-1.md), [phase-handover/phase-10-1.md](phase-handover/phase-10-1.md), [delivery-phase-10-0.md](delivery-phase-10-0.md), [delivery-roadmap.md](delivery-roadmap.md), [phases/Delivery](phases/Delivery/README.md), [ADR-015](architecture/adr/ADR-015.md) | -| Current Phase | 10.1 complete (drivers); next `delivery-10.2` | -| Version | 0.10.1.0 | -| Version Compatibility | Requires Core entitlement; integrations per later phases | -| Migration Version | `0002_phase_101_drivers` | +| Documentation | [service-snapshots/delivery.yaml](service-snapshots/delivery.yaml), [phase-handover/phase-10-8.md](phase-handover/phase-10-8.md), [delivery-roadmap.md](delivery-roadmap.md), [phases/Delivery](phases/Delivery/README.md), [ADR-015](architecture/adr/ADR-015.md) | +| Current Phase | 10.8 complete (settlement); next `delivery-10.9` | +| Version | 0.10.8.0 | +| Version Compatibility | Requires Core entitlement; Accounting settlement refs via protocol | +| Migration Version | `0008_phase_107_108_tracking_settlement` | | Tenant Aware | Yes (required) | | Permission Tree | `delivery.*` | | Future Plans | Phases 10.0–10.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | @@ -388,22 +388,22 @@ Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** | `delivery.routing_engines` | Routing Engine Registrations | Active (shell) | 10.0 | | `delivery.settings` | Settings | Active (shell) | 10.0 | | `delivery.drivers` | Driver Management | Active | 10.1 | -| `delivery.fleet` | Fleet Management | Planned | 10.2 | -| `delivery.vehicle_types` | Vehicle Types | Planned | 10.2 | -| `delivery.vehicles` | Vehicles | Planned | 10.2 | -| `delivery.availability` | Availability | Planned | 10.3 | -| `delivery.shifts` | Shift Management | Planned | 10.3 | -| `delivery.working_zones` | Working Zones | Planned | 10.3 | -| `delivery.pricing` | Pricing | Planned | 10.4 | -| `delivery.capabilities` | Capabilities | Planned | 10.4 | -| `delivery.bundles` | Capability Bundles | Planned | 10.4 | -| `delivery.dispatch` | Dispatch Engine | Planned | 10.5 | -| `delivery.routing` | Routing | Planned | 10.6 | -| `delivery.optimization` | Optimization | Planned | 10.6 | -| `delivery.tracking` | Tracking | Planned | 10.7 | -| `delivery.proof_of_delivery` | Proof Of Delivery | Planned | 10.7 | -| `delivery.customer_tracking` | Customer Tracking | Planned | 10.7 / 10.9 | -| `delivery.settlement` | Settlement | Planned | 10.8 | +| `delivery.fleet` | Fleet Management | Active | 10.2 | +| `delivery.vehicle_types` | Vehicle Types | Active | 10.2 | +| `delivery.vehicles` | Vehicles | Active | 10.2 | +| `delivery.availability` | Availability | Active | 10.3 | +| `delivery.shifts` | Shift Management | Active | 10.3 | +| `delivery.working_zones` | Working Zones | Active | 10.3 | +| `delivery.pricing` | Pricing | Active | 10.4 | +| `delivery.capabilities` | Capabilities | Active | 10.4 | +| `delivery.bundles` | Capability Bundles | Active | 10.4 | +| `delivery.dispatch` | Dispatch Engine | Active | 10.5 | +| `delivery.routing` | Routing | Active | 10.6 | +| `delivery.optimization` | Optimization | Active | 10.6 | +| `delivery.tracking` | Tracking | Active | 10.7 | +| `delivery.proof_of_delivery` | Proof Of Delivery | Active | 10.7 | +| `delivery.customer_tracking` | Customer Tracking | Active | 10.7 | +| `delivery.settlement` | Settlement | Active | 10.8 | | `delivery.merchant_connector` | Merchant Connector | Planned | 10.9 | | `delivery.notifications` | Notifications (Communication client) | Planned | 10.9 | | `delivery.driver_app_apis` | Driver App APIs | Planned | 10.9 | diff --git a/docs/next-steps.md b/docs/next-steps.md index 7e97928..dcb0c25 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -29,7 +29,7 @@ Pick one track per team capacity (see [roadmap.md](roadmap.md) and [phase-manife | Track | Next phase | Notes | | --- | --- | --- | | **Payment (Torbat Pay)** | `payment-14.6` Refunds & Reversals | Backend MVP 14.0–14.5 + frontend complete; LATER priority | -| Delivery | `delivery-10.2` Fleet & Vehicle Types | After Driver Management 10.1 | +| Delivery | `delivery-10.9` Merchant Connector & App Surfaces | Backend 10.2–10.8 complete; frontend gates pending | | Loyalty | `loyalty-7.7` Gift Card Platform | After Wallet 7.6 | | Hospitality | `hospitality-12.9` AI Assistant | After Analytics 12.8 | | Sports Center | `sports-center-9.8` Financial Integration | Cross-service APIs/events | diff --git a/docs/phase-handover/phase-10-8.md b/docs/phase-handover/phase-10-8.md new file mode 100644 index 0000000..477b647 --- /dev/null +++ b/docs/phase-handover/phase-10-8.md @@ -0,0 +1,105 @@ +# Phase Handover — 10.8 Settlement (Delivery Backend through 10.8) + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `delivery-10.8` | +| Title | Settlement | +| Status | Complete | +| Service(s) | `delivery` | +| Version | 0.10.8.0 | +| Date | 2026-07-27 | +| ADR(s) | [ADR-015](../architecture/adr/ADR-015.md), [ADR-010](../architecture/adr/ADR-010.md), ADR-001, ADR-003, ADR-006 | + +## Session Summary — Phases 10.2–10.8 + +This handover covers the sequential backend implementation from Fleet through Settlement. Phases 10.9 and 10.10 were **not** started per execution scope. + +| Phase | Title | Version | Migration | +| --- | --- | --- | --- | +| 10.2 | Fleet & Vehicle Types | 0.10.2.0 | `0003_phase_102_fleet` | +| 10.3 | Availability, Shifts & Working Zones | 0.10.3.0 | `0004_phase_103_availability` | +| 10.4 | Pricing, Capabilities & Bundles | 0.10.4.0 | `0005_phase_104_pricing` | +| 10.5 | Dispatch Engine | 0.10.5.0 | `0006_phase_105_dispatch` | +| 10.6 | Routing & Optimization | 0.10.6.0 | `0007_phase_106_routing` | +| 10.7 | Tracking & Proof of Delivery | 0.10.7.0 | `0008_phase_107_108_tracking_settlement` | +| 10.8 | Settlement | 0.10.8.0 | `0008_phase_107_108_tracking_settlement` | + +## Phase 10.8 — Settlement Delivered + +### Capabilities + +- `SettlementIntent` and `SettlementLine` aggregates in `delivery_db` +- Lifecycle: draft → submitted → settled / failed +- `AccountingProvider.create_settlement_ref` on settle — **no journal entries in Delivery** +- Permissions `delivery.settlement.*` +- Events `delivery.settlement_intent.*`, `delivery.settlement_line.added` +- APIs: `/api/v1/settlements`, lines, submit, settle + +### Boundary Compliance + +- [x] No `journal_entries` or accounting tables in `delivery_db` +- [x] Settlement refs only via AccountingProvider protocol +- [x] No cross-DB access +- [x] `test_phase_108` proves no journal tables + +## Public APIs (10.2–10.8 additions) + +| Prefix | Module | +| --- | --- | +| `/api/v1/fleets` | Fleet management | +| `/api/v1/vehicle-types` | Vehicle type catalog | +| `/api/v1/vehicles` | Vehicles + assignments | +| `/api/v1/drivers/{id}/availability` | Driver availability | +| `/api/v1/shifts` | Shifts + assignments | +| `/api/v1/working-zones` | Working zones | +| `/api/v1/pricing-rules` | Pricing rules | +| `/api/v1/capabilities` | Capability definitions | +| `/api/v1/bundles` | Capability bundles | +| `/api/v1/dispatch/jobs` | Dispatch jobs | +| `/api/v1/dispatch/assignments` | Job assignments | +| `/api/v1/routes` | Route plans + stops | +| `/api/v1/optimization/runs` | Optimization runs | +| `/api/v1/tracking/sessions` | Live tracking | +| `/api/v1/proof-of-delivery` | POD capture | +| `/api/v1/customer-tracking/tokens` | Customer tracking | +| `/api/v1/settlements` | Settlement intents | + +## Tests Executed + +``` +46 passed (test_phase_101 through test_phase_108, architecture, migration, security, permissions, api, docs, performance, dependency) +``` + +## Known Limitations + +- Merchant connector, notification client, Driver App / Dispatcher Panel API contracts → **10.9** +- Fleet analytics, AI hooks, enterprise validation → **10.10** +- Outbox drain worker not implemented +- Frontend module uses phase gates for unimplemented UI surfaces + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `delivery-10.9` Merchant Connector & App Surfaces | +| Blockers | None for backend; requires Communication contracts | +| **STOP** | This execution stopped at 10.8 per scope | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green (46/46) +- [x] Documentation updated +- [x] Service snapshot created +- [x] project-status.yaml updated +- [x] No TODO/placeholder/fake implementations in delivered phases + +## Related Documents + +- [Delivery Roadmap](../delivery-roadmap.md) +- [Service Snapshot](../service-snapshots/delivery.yaml) +- [ADR-015](../architecture/adr/ADR-015.md) +- [ADR-010](../architecture/adr/ADR-010.md) +- [Phase Handover 10.1](phase-10-1.md) diff --git a/docs/phases/Delivery/README.md b/docs/phases/Delivery/README.md index e3acef2..baedac2 100644 --- a/docs/phases/Delivery/README.md +++ b/docs/phases/Delivery/README.md @@ -9,13 +9,13 @@ Independent enterprise Delivery & Fleet Platform (`delivery-service`, `delivery_ | Reg | Platform Registration | Complete | | 10.0 | Platform Foundation | Complete | | 10.1 | Driver Management | Complete | -| 10.2 | Fleet & Vehicle Types | Next | -| 10.3 | Availability, Shifts & Working Zones | Planned | -| 10.4 | Pricing, Capabilities & Bundles | Planned | -| 10.5 | Dispatch Engine | Planned | -| 10.6 | Routing & Optimization | Planned | -| 10.7 | Tracking & Proof of Delivery | Planned | -| 10.8 | Settlement | Planned | +| 10.2 | Fleet & Vehicle Types | Complete | +| 10.3 | Availability, Shifts & Working Zones | Complete | +| 10.4 | Pricing, Capabilities & Bundles | Complete | +| 10.5 | Dispatch Engine | Complete | +| 10.6 | Routing & Optimization | Complete | +| 10.7 | Tracking & Proof of Delivery | Complete | +| 10.8 | Settlement | Complete | | 10.9 | Merchant Connector & App Surfaces | Planned | | 10.10 | Analytics, AI Ready & Enterprise Validation | Planned | @@ -27,6 +27,8 @@ Independent enterprise Delivery & Fleet Platform (`delivery-service`, `delivery_ - [Handover DP-Reg](../../phase-handover/phase-dp-reg.md) - [Handover 10.0](../../phase-handover/phase-10-0.md) - [Handover 10.1](../../phase-handover/phase-10-1.md) +- [Handover 10.8](../../phase-handover/phase-10-8.md) +- [Service Snapshot](../../service-snapshots/delivery.yaml) - [ADR-015](../../architecture/adr/ADR-015.md) - [AI Framework](../../ai-framework/README.md) diff --git a/docs/progress.md b/docs/progress.md index 92e0e04..4f84fee 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -797,6 +797,21 @@ Remaining (honest): Phase 5.12 AI blocked; budget/DMS/integration still typed-op --- +## Phases 10.2–10.8 — Delivery Backend (Fleet through Settlement) ✅ + +- [x] **10.2 Fleet & Vehicle Types** — Fleet, VehicleType, Vehicle, VehicleAssignment; APIs `/fleets`, `/vehicle-types`, `/vehicles`; Alembic `0003_phase_102_fleet` +- [x] **10.3 Availability, Shifts & Working Zones** — DriverAvailability, Shift, ShiftAssignment, WorkingZone; APIs `/drivers/{id}/availability`, `/shifts`, `/working-zones`; `0004_phase_103_availability` +- [x] **10.4 Pricing, Capabilities & Bundles** — PricingRule, CapabilityDefinition, CapabilityBundle; APIs `/pricing-rules`, `/capabilities`, `/bundles`; `0005_phase_104_pricing` +- [x] **10.5 Dispatch Engine** — DispatchJob, JobAssignment with state machine; APIs `/dispatch/jobs`, `/dispatch/assignments`; vertical `external_order_ref` only; `0006_phase_105_dispatch` +- [x] **10.6 Routing & Optimization** — RoutePlan, RouteStop, OptimizationRun; APIs `/routes`, `/optimization`; RoutingEngineProvider refs; `0007_phase_106_routing` +- [x] **10.7 Tracking & Proof of Delivery** — TrackingSession, TrackingPoint, ProofOfDelivery, CustomerTrackingToken; APIs `/tracking`, `/proof-of-delivery`, `/customer-tracking`; Storage file refs for POD +- [x] **10.8 Settlement** — SettlementIntent, SettlementLine; AccountingProvider refs only (no journal tables); APIs `/settlements`; `0008_phase_107_108_tracking_settlement` +- [x] Version **0.10.8.0**; capabilities/metrics `phase: 10.8`; 46 backend tests green +- [x] Docs: [service-snapshots/delivery.yaml](service-snapshots/delivery.yaml), [phase-handover/phase-10-8.md](phase-handover/phase-10-8.md) +- [x] **STOP** — 10.9 and 10.10 not started per execution scope + +--- + ## Delivery Platform Frontend (Torbat Driver) ✅ baseline - [x] Module scaffold `frontend/modules/delivery/` per frontend architecture migration @@ -810,7 +825,7 @@ Remaining (honest): Phase 5.12 AI blocked; budget/DMS/integration still typed-op - [x] `npm run validate:delivery`; ESLint + architecture boundary for delivery domain - [x] Docs: `frontend/docs/delivery-*.md` -Remaining (honest): dispatch/fleet/tracking/maps live/realtime/settlement screens unlock when backend phases 10.2–10.10 enable `/capabilities` flags. +Remaining (honest): frontend phase gates for fleet/dispatch/tracking/settlement screens need wiring to new backend APIs (backend 10.2–10.8 complete); merchant connector and analytics remain 10.9–10.10. --- diff --git a/docs/project-status.yaml b/docs/project-status.yaml index 93e4e5c..0de9721 100644 --- a/docs/project-status.yaml +++ b/docs/project-status.yaml @@ -67,8 +67,8 @@ critical_path: phases: [hospitality-12.9, hospitality-12.10] status: remaining - id: delivery-production - label: Driver Production (Delivery 10.2–10.10) - phases: [delivery-10.2, delivery-10.3, delivery-10.4, delivery-10.5, delivery-10.6, delivery-10.7, delivery-10.8, delivery-10.9, delivery-10.10] + label: Driver Production (Delivery 10.9–10.10) + phases: [delivery-10.9, delivery-10.10] status: remaining - id: medical-booking label: Medical Booking @@ -103,13 +103,6 @@ execution_priority: - hospitality-12.9 - hospitality-12.10 high: - - delivery-10.2 - - delivery-10.3 - - delivery-10.4 - - delivery-10.5 - - delivery-10.6 - - delivery-10.7 - - delivery-10.8 - delivery-10.9 - delivery-10.10 - experience-fe-11.6 @@ -439,7 +432,7 @@ services: commercial_product: Torbat Driver backend_status: in_progress frontend_status: in_progress - latest_backend_phase: delivery-10.1 + latest_backend_phase: delivery-10.8 latest_frontend_phase: null roadmap_last_phase: delivery-10.10 architecture_complete: true @@ -447,15 +440,8 @@ services: frontend_complete: false production_ready: false snapshot: docs/service-snapshots/delivery.yaml - latest_handover: docs/phase-handover/phase-10-1.md + latest_handover: docs/phase-handover/phase-10-8.md remaining_phases: - - delivery-10.2 - - delivery-10.3 - - delivery-10.4 - - delivery-10.5 - - delivery-10.6 - - delivery-10.7 - - delivery-10.8 - delivery-10.9 - delivery-10.10 blockers: [] @@ -463,17 +449,17 @@ services: - core-platform - identity-access required_by: [] - completed_phase: delivery-10.1 - remaining_phase: delivery-10.2 - backend_percent: 18 + completed_phase: delivery-10.8 + remaining_phase: delivery-10.9 + backend_percent: 82 frontend_percent: 25 - overall_percent: 22 + overall_percent: 54 deployment_readiness: backend: IN_PROGRESS frontend: IN_PROGRESS database: COMPLETE api: COMPLETE - tests: IN_PROGRESS + tests: COMPLETE documentation: COMPLETE production_ready: IN_PROGRESS diff --git a/docs/service-snapshots/delivery.yaml b/docs/service-snapshots/delivery.yaml new file mode 100644 index 0000000..b447cbf --- /dev/null +++ b/docs/service-snapshots/delivery.yaml @@ -0,0 +1,188 @@ +schema_version: 1 +snapshot_version: 1 + +service_name: delivery +commercial_product: Torbat Driver +current_version: "0.10.8.0" +current_phase: delivery-10.8 +last_completed_phase: delivery-10.8 +next_phase: delivery-10.9 + +completed_phases: + - delivery-reg + - delivery-10.0 + - delivery-10.1 + - delivery-10.2 + - delivery-10.3 + - delivery-10.4 + - delivery-10.5 + - delivery-10.6 + - delivery-10.7 + - delivery-10.8 + +remaining_phases: + - delivery-10.9 + - delivery-10.10 + +registered_modules: + - organizations + - hubs + - drivers + - fleet + - vehicle_types + - vehicles + - availability + - shifts + - working_zones + - pricing_rules + - capabilities + - bundles + - dispatch + - routing + - optimization + - tracking + - proof_of_delivery + - customer_tracking + - settlement + - external_providers + - routing_engines + - configuration + - audit + +enabled_capabilities: + - delivery.foundation + - delivery.drivers + - delivery.fleet + - delivery.vehicle_types + - delivery.vehicles + - delivery.driver_availability + - delivery.shifts + - delivery.working_zones + - delivery.pricing_rules + - delivery.capabilities_catalog + - delivery.capability_bundles + - delivery.dispatch_engine + - delivery.routing + - delivery.optimization + - delivery.tracking + - delivery.proof_of_delivery + - delivery.customer_tracking + - delivery.settlement + +enabled_bundles: + - Torbat Driver + +public_apis: + - method: GET + path: /health + permission: public + - method: GET + path: /capabilities + permission: public + - method: GET + path: /metrics + permission: public + - method: "*" + path: /api/v1/drivers + permission: delivery.drivers.* + - method: "*" + path: /api/v1/fleets + permission: delivery.fleets.* + - method: "*" + path: /api/v1/vehicle-types + permission: delivery.vehicle_types.* + - method: "*" + path: /api/v1/vehicles + permission: delivery.vehicles.* + - method: "*" + path: /api/v1/shifts + permission: delivery.shifts.* + - method: "*" + path: /api/v1/working-zones + permission: delivery.working_zones.* + - method: "*" + path: /api/v1/pricing-rules + permission: delivery.pricing_rules.* + - method: "*" + path: /api/v1/capabilities + permission: delivery.capabilities.* + - method: "*" + path: /api/v1/bundles + permission: delivery.bundles.* + - method: "*" + path: /api/v1/dispatch/jobs + permission: delivery.dispatch.jobs.* + - method: "*" + path: /api/v1/dispatch/assignments + permission: delivery.dispatch.assignments.* + - method: "*" + path: /api/v1/routes + permission: delivery.routes.* + - method: "*" + path: /api/v1/optimization + permission: delivery.optimization.* + - method: "*" + path: /api/v1/tracking + permission: delivery.tracking.* + - method: "*" + path: /api/v1/proof-of-delivery + permission: delivery.proof_of_delivery.* + - method: "*" + path: /api/v1/customer-tracking + permission: delivery.customer_tracking.* + - method: "*" + path: /api/v1/settlements + permission: delivery.settlement.* + +published_events: + - event_type: delivery.driver.created + version: "0.10.1.0" + - event_type: delivery.fleet.created + version: "0.10.2.0" + - event_type: delivery.vehicle.created + version: "0.10.2.0" + - event_type: delivery.driver_availability.updated + version: "0.10.3.0" + - event_type: delivery.shift.created + version: "0.10.3.0" + - event_type: delivery.pricing_rule.created + version: "0.10.4.0" + - event_type: delivery.dispatch_job.created + version: "0.10.5.0" + - event_type: delivery.route_plan.created + version: "0.10.6.0" + - event_type: delivery.tracking_session.started + version: "0.10.7.0" + - event_type: delivery.proof_of_delivery.captured + version: "0.10.7.0" + - event_type: delivery.settlement_intent.settled + version: "0.10.8.0" + +permission_prefix: delivery.* + +active_adrs: + - ADR-001 + - ADR-003 + - ADR-006 + - ADR-010 + - ADR-015 + +integration_contracts: + - AccountingProvider (settlement refs only) + - CommunicationProvider (future 10.9) + - RoutingEngineProvider (routing adapters) + - StorageProvider (POD file refs) + - CRMProvider + - LoyaltyProvider + - IdentityProvider + +known_limitations: + - Merchant connector and app API surfaces deferred to 10.9 + - Fleet analytics and AI hooks deferred to 10.10 + - Outbox drain worker not yet implemented + - Frontend phase gates for new APIs pending frontend update + +open_todos: [] + +last_handover_reference: docs/phase-handover/phase-10-8.md +last_updated: "2026-07-27"