TorbatYar/backend/services/healthcare/app/services/delivery_integration.py
Mortezakoohjani 625275e55b feat(healthcare): add backend service and finalize frontend build
Add the Healthcare platform backend (phases 13.0–13.7) with Alembic migration revision fix, production deploy script, validation reports, shared UI exports, and loyalty list page client directives so Next.js build succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:38:31 +03:30

200 lines
8.3 KiB
Python

"""Delivery integration application services — Phase 13.7."""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.events.publisher import get_event_publisher
from app.events.types import HealthcareEventType
from app.models.delivery_integration import (
DeliveryIntegrationRegistration,
DeliveryJobIntent,
DeliveryJobStatusSnapshot,
)
from app.models.types import DeliveryJobStatus, PrescriptionOrderStatus
from app.providers.mocks import MockCommunicationProvider, MockDeliveryProvider
from app.repositories.delivery_integration import (
DeliveryIntegrationRepository,
DeliveryJobIntentRepository,
DeliveryJobStatusSnapshotRepository,
)
from app.repositories.pharmacy import PharmacyRepository, PrescriptionOrderRepository
from app.schemas.delivery_integration import (
DeliveryIntegrationCreate,
DeliveryJobIntentCreate,
DeliveryStatusWebhook,
)
from app.services.foundation import _actor_id
from app.validators import validate_code
from shared.exceptions import AppError, NotFoundError
from shared.security import CurrentUser
class DeliveryIntegrationService:
def __init__(
self,
session: AsyncSession,
*,
delivery: MockDeliveryProvider | None = None,
communication: MockCommunicationProvider | None = None,
) -> None:
self.session = session
self.registrations = DeliveryIntegrationRepository(session)
self.intents = DeliveryJobIntentRepository(session)
self.snapshots = DeliveryJobStatusSnapshotRepository(session)
self.orders = PrescriptionOrderRepository(session)
self.pharmacies = PharmacyRepository(session)
self.delivery = delivery or MockDeliveryProvider()
self.communication = communication or MockCommunicationProvider()
self.events = get_event_publisher()
async def register(
self, tenant_id: UUID, body: DeliveryIntegrationCreate, *, actor: CurrentUser | None = None
) -> DeliveryIntegrationRegistration:
code = validate_code(body.code)
if await self.registrations.get_by_code(tenant_id, code):
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
entity = DeliveryIntegrationRegistration(
tenant_id=tenant_id,
clinic_id=body.clinic_id,
pharmacy_id=body.pharmacy_id,
code=code,
name=body.name,
delivery_merchant_ref=body.delivery_merchant_ref,
webhook_secret_ref=body.webhook_secret_ref,
status=body.status,
config=body.config,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
await self.registrations.add(entity)
self.events.publish(
event_type=HealthcareEventType.DELIVERY_INTEGRATION_REGISTERED,
aggregate_type="delivery_integration_registration",
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_intent_for_order(
self,
tenant_id: UUID,
prescription_order_id: UUID,
body: DeliveryJobIntentCreate,
*,
actor: CurrentUser | None = None,
) -> DeliveryJobIntent:
existing = await self.intents.get_by_idempotency(tenant_id, body.idempotency_key)
if existing:
return existing
order = await self.orders.get(tenant_id, prescription_order_id)
if order is None:
raise NotFoundError("سفارش نسخه یافت نشد")
if order.status != PrescriptionOrderStatus.READY:
raise AppError(
"سفارش باید آماده باشد",
status_code=409,
error_code="prescription_not_ready",
)
registration = await self.registrations.get(tenant_id, body.registration_id)
if registration is None:
raise NotFoundError("ثبت‌نام Delivery یافت نشد")
code = validate_code(body.code)
if await self.intents.get_by_code(tenant_id, code):
raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409)
delivery_result = await self.delivery.request_delivery(
tenant_id=tenant_id,
request={
"prescription_order_id": str(prescription_order_id),
"merchant_ref": registration.delivery_merchant_ref,
"pickup_address": body.pickup_address,
"dropoff_address": body.dropoff_address,
},
)
entity = DeliveryJobIntent(
tenant_id=tenant_id,
registration_id=body.registration_id,
prescription_order_id=prescription_order_id,
code=code,
delivery_job_ref=delivery_result["delivery_ref"],
status=DeliveryJobStatus.QUEUED,
idempotency_key=body.idempotency_key,
pickup_address=body.pickup_address,
dropoff_address=body.dropoff_address,
metadata_json=body.metadata_json,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
await self.intents.add(entity)
await self.snapshots.add(
DeliveryJobStatusSnapshot(
tenant_id=tenant_id,
delivery_job_intent_id=entity.id,
status=DeliveryJobStatus.QUEUED,
external_status="queued",
recorded_at=datetime.now(timezone.utc),
payload=delivery_result,
)
)
self.events.publish(
event_type=HealthcareEventType.DELIVERY_JOB_INTENT_CREATED,
aggregate_type="delivery_job_intent",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "delivery_ref": entity.delivery_job_ref},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def apply_status_webhook(
self, tenant_id: UUID, body: DeliveryStatusWebhook
) -> DeliveryJobIntent:
intent = await self.intents.get_by_delivery_ref(tenant_id, body.delivery_job_ref)
if intent is None:
raise NotFoundError("Delivery job intent یافت نشد")
intent.status = body.status
intent.version += 1
await self.snapshots.add(
DeliveryJobStatusSnapshot(
tenant_id=tenant_id,
delivery_job_intent_id=intent.id,
status=body.status,
external_status=body.external_status,
recorded_at=datetime.now(timezone.utc),
payload=body.payload,
)
)
self.events.publish(
event_type=HealthcareEventType.DELIVERY_JOB_STATUS_UPDATED,
aggregate_type="delivery_job_intent",
aggregate_id=intent.id,
tenant_id=tenant_id,
payload={"status": body.status.value, "delivery_ref": body.delivery_job_ref},
)
if body.status == DeliveryJobStatus.DELIVERED:
order = await self.orders.get(tenant_id, intent.prescription_order_id)
if order:
patient_phone = "unknown"
await self.communication.send(
tenant_id=tenant_id,
channel="sms",
template_key="healthcare.delivery.delivered",
to=patient_phone,
payload={"delivery_ref": body.delivery_job_ref},
)
await self.session.commit()
await self.session.refresh(intent)
return intent
async def list_intents(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
return list(await self.intents.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def list_registrations(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20):
return list(await self.registrations.list_by_tenant(tenant_id, offset=offset, limit=limit))