TorbatYar/backend/services/delivery/app/services/tracking.py
Mortezakoohjani 72077908f1 feat(delivery): deploy backend phases 10.2-10.8 through settlement
Ship fleet, availability, pricing, dispatch, routing, tracking, and settlement on delivery-service 0.10.8.0 with migrations and phase tests green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:01:23 +03:30

345 lines
13 KiB
Python

"""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