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>
345 lines
13 KiB
Python
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
|