TorbatYar/backend/services/healthcare/app/services/foundation.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

787 lines
28 KiB
Python

"""Healthcare foundation application services — Phase 13.0."""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from uuid import UUID
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.events.publisher import get_event_publisher
from app.events.types import HealthcareEventType
from app.models.foundation import (
Branch,
Clinic,
Department,
ExternalProviderConfig,
HealthcareConfiguration,
HealthcareSetting,
)
from app.models.types import AuditAction
from app.repositories.foundation import (
BranchRepository,
ClinicRepository,
DepartmentRepository,
ExternalProviderConfigRepository,
HealthcareConfigurationRepository,
HealthcareSettingRepository,
)
from app.schemas.foundation import (
BranchCreate,
BranchUpdate,
ClinicCreate,
ClinicUpdate,
DepartmentCreate,
DepartmentUpdate,
ExternalProviderConfigCreate,
ExternalProviderConfigUpdate,
HealthcareConfigurationCreate,
HealthcareConfigurationUpdate,
HealthcareSettingUpsert,
)
from app.services.audit_service import AuditService
from app.validators import (
ensure_optimistic_version,
validate_code,
validate_currency_code,
validate_non_empty,
)
from shared.exceptions import AppError, NotFoundError
from shared.security import CurrentUser
def _apply_update(entity, data: dict) -> None:
for key, value in data.items():
setattr(entity, key, value)
def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, value in data.items():
if isinstance(value, UUID):
out[key] = str(value)
elif isinstance(value, (datetime, date)):
out[key] = value.isoformat()
elif hasattr(value, "value"):
out[key] = value.value
else:
out[key] = value
return out
def _actor_id(actor: CurrentUser | None) -> str | None:
return actor.user_id if actor else None
class ClinicService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = ClinicRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: ClinicCreate,
*,
actor: CurrentUser | None = None,
) -> Clinic:
code = validate_code(body.code)
name = validate_non_empty(body.name, "name")
currency = validate_currency_code(body.currency_code)
if await self.repo.get_by_code(tenant_id, code):
raise AppError(
"کد کلینیک تکراری است",
error_code="duplicate_code",
status_code=409,
)
entity = Clinic(
tenant_id=tenant_id,
code=code,
name=name,
description=body.description,
status=body.status,
legal_name=body.legal_name,
timezone=body.timezone,
language=body.language,
currency_code=currency,
settings=body.settings,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.repo.add(entity)
except IntegrityError as exc:
raise AppError(
"کد کلینیک تکراری است",
error_code="duplicate_code",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="clinic",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=HealthcareEventType.CLINIC_CREATED,
aggregate_type="clinic",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "name": entity.name},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> Clinic:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("کلینیک یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[Clinic]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: ClinicUpdate,
*,
actor: CurrentUser | None = None,
) -> Clinic:
entity = await self.get(tenant_id, entity_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "name" in data and data["name"] is not None:
data["name"] = validate_non_empty(data["name"], "name")
if "currency_code" in data and data["currency_code"] is not None:
data["currency_code"] = validate_currency_code(data["currency_code"])
_apply_update(entity, data)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="clinic",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=HealthcareEventType.CLINIC_UPDATED,
aggregate_type="clinic",
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 soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> Clinic:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="clinic",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class BranchService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = BranchRepository(session)
self.clinics = ClinicRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: BranchCreate,
*,
actor: CurrentUser | None = None,
) -> Branch:
clinic = await self.clinics.get(tenant_id, body.clinic_id)
if clinic is None:
raise NotFoundError("کلینیک یافت نشد")
code = validate_code(body.code)
name = validate_non_empty(body.name, "name")
entity = Branch(
tenant_id=tenant_id,
clinic_id=body.clinic_id,
code=code,
name=name,
description=body.description,
status=body.status,
address=body.address,
timezone=body.timezone,
working_hours=body.working_hours,
metadata_json=body.metadata_json,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.repo.add(entity)
except IntegrityError as exc:
raise AppError(
"کد شعبه تکراری است",
error_code="duplicate_code",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="branch",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=HealthcareEventType.BRANCH_CREATED,
aggregate_type="branch",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "clinic_id": str(entity.clinic_id)},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> Branch:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("شعبه یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[Branch]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: BranchUpdate,
*,
actor: CurrentUser | None = None,
) -> Branch:
entity = await self.get(tenant_id, entity_id)
data = body.model_dump(exclude_unset=True)
if "name" in data and data["name"] is not None:
data["name"] = validate_non_empty(data["name"], "name")
_apply_update(entity, data)
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="branch",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=HealthcareEventType.BRANCH_UPDATED,
aggregate_type="branch",
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 soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> Branch:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="branch",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class DepartmentService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = DepartmentRepository(session)
self.clinics = ClinicRepository(session)
self.branches = BranchRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: DepartmentCreate,
*,
actor: CurrentUser | None = None,
) -> Department:
clinic = await self.clinics.get(tenant_id, body.clinic_id)
if clinic is None:
raise NotFoundError("کلینیک یافت نشد")
if body.branch_id is not None:
branch = await self.branches.get(tenant_id, body.branch_id)
if branch is None:
raise NotFoundError("شعبه یافت نشد")
code = validate_code(body.code)
name = validate_non_empty(body.name, "name")
entity = Department(
tenant_id=tenant_id,
clinic_id=body.clinic_id,
branch_id=body.branch_id,
code=code,
name=name,
description=body.description,
status=body.status,
specialty=body.specialty,
metadata_json=body.metadata_json,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.repo.add(entity)
except IntegrityError as exc:
raise AppError(
"کد بخش تکراری است",
error_code="duplicate_code",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="department",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=HealthcareEventType.DEPARTMENT_CREATED,
aggregate_type="department",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "clinic_id": str(entity.clinic_id)},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> Department:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("بخش یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[Department]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: DepartmentUpdate,
*,
actor: CurrentUser | None = None,
) -> Department:
entity = await self.get(tenant_id, entity_id)
data = body.model_dump(exclude_unset=True)
if "name" in data and data["name"] is not None:
data["name"] = validate_non_empty(data["name"], "name")
if "branch_id" in data and data["branch_id"] is not None:
branch = await self.branches.get(tenant_id, data["branch_id"])
if branch is None:
raise NotFoundError("شعبه یافت نشد")
_apply_update(entity, data)
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="department",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=HealthcareEventType.DEPARTMENT_UPDATED,
aggregate_type="department",
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 soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> Department:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="department",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class ExternalProviderConfigService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = ExternalProviderConfigRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: ExternalProviderConfigCreate,
*,
actor: CurrentUser | None = None,
) -> ExternalProviderConfig:
code = validate_code(body.code)
name = validate_non_empty(body.name, "name")
adapter_key = validate_non_empty(body.adapter_key, "adapter_key")
if await self.repo.get_by_code(tenant_id, code):
raise AppError(
"کد ارائه‌دهنده تکراری است",
error_code="duplicate_code",
status_code=409,
)
entity = ExternalProviderConfig(
tenant_id=tenant_id,
clinic_id=body.clinic_id,
code=code,
name=name,
provider_kind=body.provider_kind,
adapter_key=adapter_key,
status=body.status,
priority=body.priority,
credentials_ref=body.credentials_ref,
config=body.config,
capabilities=body.capabilities,
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="external_provider_config",
entity_id=entity.id,
action=AuditAction.REGISTER,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=HealthcareEventType.EXTERNAL_PROVIDER_REGISTERED,
aggregate_type="external_provider_config",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "adapter_key": entity.adapter_key},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> ExternalProviderConfig:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("ارائه‌دهنده یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[ExternalProviderConfig]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: ExternalProviderConfigUpdate,
*,
actor: CurrentUser | None = None,
) -> ExternalProviderConfig:
entity = await self.get(tenant_id, entity_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "name" in data and data["name"] is not None:
data["name"] = validate_non_empty(data["name"], "name")
if "adapter_key" in data and data["adapter_key"] is not None:
data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key")
_apply_update(entity, data)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="external_provider_config",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=HealthcareEventType.EXTERNAL_PROVIDER_UPDATED,
aggregate_type="external_provider_config",
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 soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> ExternalProviderConfig:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="external_provider_config",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class HealthcareConfigurationService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = HealthcareConfigurationRepository(session)
self.clinics = ClinicRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: HealthcareConfigurationCreate,
*,
actor: CurrentUser | None = None,
) -> HealthcareConfiguration:
clinic = await self.clinics.get(tenant_id, body.clinic_id)
if clinic is None:
raise NotFoundError("کلینیک یافت نشد")
code = validate_code(body.code)
currency = validate_currency_code(body.currency_code)
entity = HealthcareConfiguration(
tenant_id=tenant_id,
clinic_id=body.clinic_id,
branch_id=body.branch_id,
code=code,
working_hours=body.working_hours,
clinical_policies=body.clinical_policies,
privacy_policies=body.privacy_policies,
custom_fields=body.custom_fields,
timezone=body.timezone,
language=body.language,
currency_code=currency,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.repo.add(entity)
except IntegrityError as exc:
raise AppError(
"کد پیکربندی تکراری است",
error_code="duplicate_code",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="healthcare_configuration",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=HealthcareEventType.CONFIGURATION_CREATED,
aggregate_type="healthcare_configuration",
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, entity_id: UUID) -> HealthcareConfiguration:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("پیکربندی یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[HealthcareConfiguration]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: HealthcareConfigurationUpdate,
*,
actor: CurrentUser | None = None,
) -> HealthcareConfiguration:
entity = await self.get(tenant_id, entity_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "currency_code" in data and data["currency_code"] is not None:
data["currency_code"] = validate_currency_code(data["currency_code"])
_apply_update(entity, data)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="healthcare_configuration",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=HealthcareEventType.CONFIGURATION_UPDATED,
aggregate_type="healthcare_configuration",
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 soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> HealthcareConfiguration:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="healthcare_configuration",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class HealthcareSettingService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = HealthcareSettingRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def upsert(
self,
tenant_id: UUID,
body: HealthcareSettingUpsert,
*,
actor: CurrentUser | None = None,
) -> HealthcareSetting:
key = validate_non_empty(body.key, "key")
existing = await self.repo.get_by_key(
tenant_id,
key,
clinic_id=body.clinic_id,
branch_id=body.branch_id,
)
if existing is None:
entity = HealthcareSetting(
tenant_id=tenant_id,
clinic_id=body.clinic_id,
branch_id=body.branch_id,
key=key,
value=body.value,
description=body.description,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
await self.repo.add(entity)
else:
entity = existing
entity.value = body.value
entity.description = body.description
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="healthcare_setting",
entity_id=entity.id,
action=AuditAction.UPDATE if existing else AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=HealthcareEventType.SETTING_UPDATED,
aggregate_type="healthcare_setting",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"key": entity.key},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[HealthcareSetting]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))