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>
321 lines
12 KiB
Python
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
|