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>
282 lines
11 KiB
Python
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
|