TorbatYar/backend/services/delivery/app/services/dispatch.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

282 lines
11 KiB
Python

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