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

321 lines
12 KiB
Python

"""Doctor and Patient 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.profiles import Doctor, Patient
from app.models.types import AuditAction
from app.repositories.foundation import ClinicRepository, DepartmentRepository
from app.repositories.profiles import DoctorRepository, PatientRepository
from app.schemas.profiles import (
DoctorCreate,
DoctorUpdate,
PatientCreate,
PatientUpdate,
)
from app.services.audit_service import AuditService
from app.services.foundation import _actor_id, _apply_update, _jsonable_changes
from app.validators import ensure_optimistic_version, validate_code, validate_non_empty
from shared.exceptions import AppError, NotFoundError
from shared.security import CurrentUser
class DoctorService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = DoctorRepository(session)
self.clinics = ClinicRepository(session)
self.departments = DepartmentRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: DoctorCreate,
*,
actor: CurrentUser | None = None,
) -> Doctor:
user_id = validate_non_empty(body.user_id, "user_id")
code = validate_code(body.code)
display_name = validate_non_empty(body.display_name, "display_name")
if body.clinic_id is not None:
clinic = await self.clinics.get(tenant_id, body.clinic_id)
if clinic is None:
raise NotFoundError("کلینیک یافت نشد")
if body.department_id is not None:
department = await self.departments.get(tenant_id, body.department_id)
if department is None:
raise NotFoundError("بخش یافت نشد")
if await self.repo.get_by_code(tenant_id, code):
raise AppError(
"کد پزشک تکراری است",
error_code="duplicate_code",
status_code=409,
)
if await self.repo.get_by_user_id(tenant_id, user_id):
raise AppError(
"پروفایل پزشک برای این کاربر وجود دارد",
error_code="duplicate_user",
status_code=409,
)
entity = Doctor(
tenant_id=tenant_id,
user_id=user_id,
clinic_id=body.clinic_id,
department_id=body.department_id,
code=code,
display_name=display_name,
specialty=body.specialty,
license_number=body.license_number,
status=body.status,
contact=body.contact,
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="doctor",
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.DOCTOR_CREATED,
aggregate_type="doctor",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "user_id": entity.user_id},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> Doctor:
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[Doctor]:
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: DoctorUpdate,
*,
actor: CurrentUser | None = None,
) -> Doctor:
entity = await self.get(tenant_id, entity_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "display_name" in data and data["display_name"] is not None:
data["display_name"] = validate_non_empty(data["display_name"], "display_name")
if "clinic_id" in data and data["clinic_id"] is not None:
clinic = await self.clinics.get(tenant_id, data["clinic_id"])
if clinic is None:
raise NotFoundError("کلینیک یافت نشد")
if "department_id" in data and data["department_id"] is not None:
department = await self.departments.get(tenant_id, data["department_id"])
if department is None:
raise NotFoundError("بخش یافت نشد")
_apply_update(entity, data)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="doctor",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=HealthcareEventType.DOCTOR_UPDATED,
aggregate_type="doctor",
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,
) -> Doctor:
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="doctor",
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 PatientService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = PatientRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: PatientCreate,
*,
actor: CurrentUser | None = None,
) -> Patient:
user_id = validate_non_empty(body.user_id, "user_id")
code = validate_code(body.code)
display_name = validate_non_empty(body.display_name, "display_name")
if await self.repo.get_by_code(tenant_id, code):
raise AppError(
"کد بیمار تکراری است",
error_code="duplicate_code",
status_code=409,
)
if await self.repo.get_by_user_id(tenant_id, user_id):
raise AppError(
"پروفایل بیمار برای این کاربر وجود دارد",
error_code="duplicate_user",
status_code=409,
)
entity = Patient(
tenant_id=tenant_id,
user_id=user_id,
code=code,
display_name=display_name,
status=body.status,
date_of_birth=body.date_of_birth,
gender=body.gender,
contact=body.contact,
emergency_contact=body.emergency_contact,
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="patient",
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.PATIENT_CREATED,
aggregate_type="patient",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "user_id": entity.user_id},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> Patient:
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[Patient]:
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: PatientUpdate,
*,
actor: CurrentUser | None = None,
) -> Patient:
entity = await self.get(tenant_id, entity_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "display_name" in data and data["display_name"] is not None:
data["display_name"] = validate_non_empty(data["display_name"], "display_name")
_apply_update(entity, data)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="patient",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=HealthcareEventType.PATIENT_UPDATED,
aggregate_type="patient",
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,
) -> Patient:
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="patient",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity