diff --git a/backend/services/healthcare/Dockerfile.dev b/backend/services/healthcare/Dockerfile.dev new file mode 100644 index 0000000..8faa773 --- /dev/null +++ b/backend/services/healthcare/Dockerfile.dev @@ -0,0 +1,22 @@ +# Dev image: dependencies only — code mounted with uvicorn --reload +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/shared-lib/ /shared-lib/ +COPY backend/services/healthcare/requirements.txt /app/requirements.txt + +RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \ + && pip install --upgrade pip \ + && pip install -r requirements.txt + +EXPOSE 8010 diff --git a/backend/services/healthcare/README.md b/backend/services/healthcare/README.md new file mode 100644 index 0000000..64f8efc --- /dev/null +++ b/backend/services/healthcare/README.md @@ -0,0 +1,64 @@ +# Healthcare Platform (Torbat Healthcare) + +| Field | Value | +| --- | --- | +| Service | `healthcare-service` | +| Database | `healthcare_db` | +| Port | 8010 | +| Permission prefix | `healthcare.*` | +| Version | 0.13.0.0 | +| Phase | 13.0 Healthcare Foundation | + +## Owns + +- Clinics, branches, departments, configuration/settings shells +- Doctor and patient profile shells (Identity user refs only) +- External provider **registrations** (contracts only) +- Healthcare roles/permissions catalog shells +- Healthcare audit log + transactional outbox +- Health / capabilities / metrics discovery +- Publish-only `healthcare.*` events + +## Does not own + +- Accounting journals / billing posting +- Communication SMS/email providers +- Loyalty ledger +- Identity user administration +- Appointment booking, visit workflows, medical records, pharmacy (phases 13.1–13.7) +- Delivery dispatch engines +- Frontend UI + +## APIs + +| Method | Path | +| --- | --- | +| GET | `/health` | +| GET | `/capabilities` | +| GET | `/metrics` | +| CRUD | `/api/v1/clinics` | +| CRUD | `/api/v1/branches` | +| CRUD | `/api/v1/departments` | +| CRUD | `/api/v1/doctors` | +| CRUD | `/api/v1/patients` | +| CRUD | `/api/v1/external-providers` | +| CRUD | `/api/v1/configurations` | +| PUT/GET | `/api/v1/settings` | +| GET | `/api/v1/audit` | +| GET | `/api/v1/permissions/catalog` | + +## Run (dev) + +```bash +export HEALTHCARE_DATABASE_URL=postgresql+asyncpg://... +export HEALTHCARE_DATABASE_URL_SYNC=postgresql+psycopg://... +python scripts/ensure_db.py +alembic upgrade head +uvicorn app.main:app --host 0.0.0.0 --port 8010 --reload +``` + +## Tests + +```bash +pytest +``` diff --git a/backend/services/healthcare/alembic.ini b/backend/services/healthcare/alembic.ini new file mode 100644 index 0000000..545b0df --- /dev/null +++ b/backend/services/healthcare/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +path_separator = os +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/services/healthcare/alembic/env.py b/backend/services/healthcare/alembic/env.py new file mode 100644 index 0000000..ba24b6c --- /dev/null +++ b/backend/services/healthcare/alembic/env.py @@ -0,0 +1,42 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.core.database import Base +import app.models # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url_sync) +if config.config_file_name: + fileConfig(config.config_file_name) +target_metadata = Base.metadata + + +def run_migrations_offline(): + context.configure( + url=settings.database_url_sync, + target_metadata=target_metadata, + literal_binds=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/services/healthcare/alembic/versions/0001_initial.py b/backend/services/healthcare/alembic/versions/0001_initial.py new file mode 100644 index 0000000..2054169 --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0001_initial.py @@ -0,0 +1,19 @@ +"""Initial Delivery schema — Phase 13.0 foundation.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + Base.metadata.drop_all(bind=bind) diff --git a/backend/services/healthcare/alembic/versions/0002_phase_131_appointments.py b/backend/services/healthcare/alembic/versions/0002_phase_131_appointments.py new file mode 100644 index 0000000..c1b2fc6 --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0002_phase_131_appointments.py @@ -0,0 +1,26 @@ +"""Phase 13.1 — Appointment Engine.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0002_phase_131_appointments" +down_revision = "0001_initial" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "waiting_list_entries", + "appointments", + "appointment_types", + "availability_windows", + "schedules", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0003_phase_132_doctor_panel.py b/backend/services/healthcare/alembic/versions/0003_phase_132_doctor_panel.py new file mode 100644 index 0000000..3aebb20 --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0003_phase_132_doctor_panel.py @@ -0,0 +1,19 @@ +"""Phase 13.2 — Doctor Panel.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0003_phase_132_doctor_panel" +down_revision = "0002_phase_131_appointments" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ("visit_notes", "visit_sessions"): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0004_phase_133_clinic_management.py b/backend/services/healthcare/alembic/versions/0004_phase_133_clinic_management.py new file mode 100644 index 0000000..d3f2dee --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0004_phase_133_clinic_management.py @@ -0,0 +1,24 @@ +"""Phase 13.3 — Clinic Management.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0004_phase_133_clinic_management" +down_revision = "0003_phase_132_doctor_panel" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "operating_hours_policies", + "clinic_policies", + "staff_assignments", + "clinic_services", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0005_phase_134_patient_portal.py b/backend/services/healthcare/alembic/versions/0005_phase_134_patient_portal.py new file mode 100644 index 0000000..3eeb283 --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0005_phase_134_patient_portal.py @@ -0,0 +1,23 @@ +"""Phase 13.4 — Patient Portal.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0005_phase_134_patient_portal" +down_revision = "0004_phase_133_clinic_management" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "patient_notification_preferences", + "patient_document_refs", + "patient_portal_profiles", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0006_phase_135_medical_record.py b/backend/services/healthcare/alembic/versions/0006_phase_135_medical_record.py new file mode 100644 index 0000000..9bf5a4b --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0006_phase_135_medical_record.py @@ -0,0 +1,27 @@ +"""Phase 13.5 — Medical Record.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0006_phase_135_medical_record" +down_revision = "0005_phase_134_patient_portal" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "medical_record_access_logs", + "immunization_records", + "medication_statements", + "allergies", + "conditions", + "encounters", + "medical_records", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0007_phase_136_pharmacy_network.py b/backend/services/healthcare/alembic/versions/0007_phase_136_pharmacy_network.py new file mode 100644 index 0000000..ce4b7bb --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0007_phase_136_pharmacy_network.py @@ -0,0 +1,24 @@ +"""Phase 13.6 — Pharmacy Network.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0007_phase_136_pharmacy_network" +down_revision = "0006_phase_135_medical_record" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "prescription_status_history", + "prescription_lines", + "prescription_orders", + "pharmacies", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/alembic/versions/0008_phase_137_delivery_integration.py b/backend/services/healthcare/alembic/versions/0008_phase_137_delivery_integration.py new file mode 100644 index 0000000..c1aa37b --- /dev/null +++ b/backend/services/healthcare/alembic/versions/0008_phase_137_delivery_integration.py @@ -0,0 +1,23 @@ +"""Phase 13.7 — Delivery Integration.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0008_phase_137_delivery" +down_revision = "0007_phase_136_pharmacy_network" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + for table in ( + "delivery_job_status_snapshots", + "delivery_job_intents", + "delivery_integration_registrations", + ): + op.drop_table(table) diff --git a/backend/services/healthcare/app/__init__.py b/backend/services/healthcare/app/__init__.py new file mode 100644 index 0000000..558b479 --- /dev/null +++ b/backend/services/healthcare/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.13.7.0" diff --git a/backend/services/healthcare/app/api/deps.py b/backend/services/healthcare/app/api/deps.py new file mode 100644 index 0000000..56d4194 --- /dev/null +++ b/backend/services/healthcare/app/api/deps.py @@ -0,0 +1,39 @@ +"""Common API dependencies.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user +from shared.exceptions import TenantNotResolvedError +from shared.pagination import PaginationParams +from shared.security import CurrentUser +from shared.tenant import STATE_TENANT_ID + +__all__ = [ + "get_db", + "get_pagination", + "require_tenant", + "get_current_user", + "AsyncSession", + "CurrentUser", +] + + +def get_pagination( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=500), +) -> PaginationParams: + return PaginationParams(page=page, page_size=page_size) + + +def require_tenant(request: Request) -> UUID: + tenant_id = getattr(request.state, STATE_TENANT_ID, None) + if tenant_id is None: + raise TenantNotResolvedError( + "tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید." + ) + return tenant_id diff --git a/backend/services/healthcare/app/api/patient_deps.py b/backend/services/healthcare/app/api/patient_deps.py new file mode 100644 index 0000000..bf5163a --- /dev/null +++ b/backend/services/healthcare/app/api/patient_deps.py @@ -0,0 +1,22 @@ +"""Patient-scoped API dependencies — Phase 13.4.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import Depends, Header +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.repositories.profiles import PatientRepository +from shared.exceptions import NotFoundError + + +async def require_patient_context( + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + x_patient_id: UUID = Header(..., alias="X-Patient-ID"), +) -> UUID: + repo = PatientRepository(db) + if await repo.get(tenant_id, x_patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + return x_patient_id diff --git a/backend/services/healthcare/app/api/permissions.py b/backend/services/healthcare/app/api/permissions.py new file mode 100644 index 0000000..0484920 --- /dev/null +++ b/backend/services/healthcare/app/api/permissions.py @@ -0,0 +1,48 @@ +"""Permission enforcement helpers for Delivery APIs.""" +from __future__ import annotations + +from collections.abc import Callable + +from fastapi import Depends + +from app.core.config import settings +from app.core.security import get_current_user +from shared.exceptions import ForbiddenError +from shared.security import CurrentUser + +_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"}) + + +def user_has_permission(user: CurrentUser, permission: str) -> bool: + if any(role in _ADMIN_ROLES for role in user.roles): + return True + roles = set(user.roles) + if "healthcare.manage" in roles or permission in roles: + return True + if "healthcare.view" in roles and permission.endswith(".view"): + return True + parts = permission.split(".") + if len(parts) >= 3: + manage = f"{parts[0]}.{parts[1]}.manage" + if manage in roles: + return True + return False + + +def require_permissions(*permissions: str) -> Callable: + """Deny unless AUTH is off, user is admin, or any listed permission is present.""" + + async def _dependency( + user: CurrentUser = Depends(get_current_user), + ) -> CurrentUser: + if not settings.auth_required: + return user + if any(user_has_permission(user, perm) for perm in permissions): + return user + raise ForbiddenError( + "دسترسی مجاز نیست", + error_code="permission_denied", + details={"required": list(permissions)}, + ) + + return _dependency diff --git a/backend/services/healthcare/app/api/v1/__init__.py b/backend/services/healthcare/app/api/v1/__init__.py new file mode 100644 index 0000000..a63559e --- /dev/null +++ b/backend/services/healthcare/app/api/v1/__init__.py @@ -0,0 +1,101 @@ +from fastapi import APIRouter + +from app.api.v1 import ( + appointments, + audit, + branches, + clinic_management, + clinics, + configurations, + delivery_integration, + departments, + doctor_panel, + doctors, + external_providers, + medical_records, + patient_portal, + patients, + permissions, + pharmacy, + settings, +) + +api_router = APIRouter() +api_router.include_router(clinics.router, prefix="/clinics", tags=["clinics"]) +api_router.include_router(branches.router, prefix="/branches", tags=["branches"]) +api_router.include_router(departments.router, prefix="/departments", tags=["departments"]) +api_router.include_router(doctors.router, prefix="/doctors", tags=["doctors"]) +api_router.include_router(patients.router, prefix="/patients", tags=["patients"]) +api_router.include_router( + appointments.schedules_router, prefix="/schedules", tags=["schedules"] +) +api_router.include_router( + appointments.availability_router, prefix="/availability-windows", tags=["availability"] +) +api_router.include_router( + appointments.appointment_types_router, prefix="/appointment-types", tags=["appointment-types"] +) +api_router.include_router( + appointments.appointments_router, prefix="/appointments", tags=["appointments"] +) +api_router.include_router( + appointments.waiting_list_router, prefix="/waiting-list", tags=["waiting-list"] +) +api_router.include_router( + doctor_panel.doctor_panel_router, prefix="/doctor-panel", tags=["doctor-panel"] +) +api_router.include_router( + doctor_panel.visit_sessions_router, prefix="/visit-sessions", tags=["visit-sessions"] +) +api_router.include_router( + doctor_panel.visit_notes_router, prefix="/visit-notes", tags=["visit-notes"] +) +api_router.include_router( + clinic_management.services_router, prefix="/clinic-services", tags=["clinic-services"] +) +api_router.include_router( + clinic_management.staff_router, prefix="/staff-assignments", tags=["staff-assignments"] +) +api_router.include_router( + clinic_management.policies_router, prefix="/clinic-policies", tags=["clinic-policies"] +) +api_router.include_router( + clinic_management.hours_router, prefix="/operating-hours-policies", tags=["operating-hours"] +) +api_router.include_router( + patient_portal.router, prefix="/patient-portal", tags=["patient-portal"] +) +api_router.include_router( + medical_records.records_router, prefix="/medical-records", tags=["medical-records"] +) +api_router.include_router( + medical_records.encounters_router, prefix="/encounters", tags=["encounters"] +) +api_router.include_router(pharmacy.pharmacies_router, prefix="/pharmacies", tags=["pharmacies"]) +api_router.include_router( + pharmacy.prescription_orders_router, prefix="/prescription-orders", tags=["prescription-orders"] +) +api_router.include_router( + delivery_integration.integrations_router, + prefix="/delivery-integrations", + tags=["delivery-integrations"], +) +api_router.include_router( + delivery_integration.intents_router, prefix="/delivery-job-intents", tags=["delivery-job-intents"] +) +api_router.include_router( + delivery_integration.webhooks_router, prefix="/webhooks", tags=["webhooks"] +) +api_router.include_router( + external_providers.router, + prefix="/external-providers", + tags=["external-providers"], +) +api_router.include_router( + configurations.router, prefix="/configurations", tags=["configurations"] +) +api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) +api_router.include_router(audit.router, prefix="/audit", tags=["audit"]) +api_router.include_router( + permissions.router, prefix="/permissions", tags=["permissions"] +) diff --git a/backend/services/healthcare/app/api/v1/appointments.py b/backend/services/healthcare/app/api/v1/appointments.py new file mode 100644 index 0000000..a2baecc --- /dev/null +++ b/backend/services/healthcare/app/api/v1/appointments.py @@ -0,0 +1,233 @@ +"""Appointment scheduling APIs — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.appointments import AppointmentCommands +from app.models.types import AppointmentStatus +from app.permissions.definitions import APPOINTMENTS_MANAGE, APPOINTMENTS_VIEW, SCHEDULES_MANAGE, SCHEDULES_VIEW +from app.queries.appointments import AppointmentQueries +from app.schemas.appointments import ( + AppointmentCreate, + AppointmentLifecycleRequest, + AppointmentListResponse, + AppointmentRead, + AppointmentReschedule, + AppointmentTypeCreate, + AppointmentTypeRead, + AvailabilityWindowCreate, + AvailabilityWindowRead, + ScheduleCreate, + ScheduleRead, + WaitingListEntryCreate, + WaitingListEntryRead, +) +from app.specifications.appointments import ALLOWED_SORT_FIELDS, AppointmentListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +schedules_router = APIRouter() +availability_router = APIRouter() +appointment_types_router = APIRouter() +appointments_router = APIRouter() +waiting_list_router = APIRouter() + + +def _appointment_spec( + status_filter: AppointmentStatus | None = Query(default=None, alias="status"), + doctor_id: UUID | None = Query(default=None), + clinic_id: UUID | None = Query(default=None), + patient_id: UUID | None = Query(default=None), + date_from: datetime | None = Query(default=None), + date_to: datetime | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="scheduled_start"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> AppointmentListSpec: + if sort_by not in ALLOWED_SORT_FIELDS: + sort_by = "scheduled_start" + return AppointmentListSpec( + status=status_filter, + doctor_id=doctor_id, + clinic_id=clinic_id, + patient_id=patient_id, + date_from=date_from, + date_to=date_to, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@schedules_router.post("", response_model=ScheduleRead, status_code=status.HTTP_201_CREATED) +async def create_schedule( + body: ScheduleCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SCHEDULES_MANAGE)), +): + return await AppointmentCommands(db).create_schedule(tenant_id, body, actor=user) + + +@schedules_router.get("", response_model=list[ScheduleRead]) +async def list_schedules( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW)), +): + return await AppointmentQueries(db).list_schedules( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@availability_router.post("", response_model=AvailabilityWindowRead, status_code=status.HTTP_201_CREATED) +async def create_availability( + body: AvailabilityWindowCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SCHEDULES_MANAGE)), +): + return await AppointmentCommands(db).create_availability(tenant_id, body, actor=user) + + +@availability_router.get("", response_model=list[AvailabilityWindowRead]) +async def list_availability( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SCHEDULES_VIEW)), +): + return await AppointmentQueries(db).list_availability( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@appointment_types_router.post("", response_model=AppointmentTypeRead, status_code=status.HTTP_201_CREATED) +async def create_appointment_type( + body: AppointmentTypeCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).create_type(tenant_id, body, actor=user) + + +@appointment_types_router.get("", response_model=list[AppointmentTypeRead]) +async def list_appointment_types( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + return await AppointmentQueries(db).list_types( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@appointments_router.post("", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED) +async def book_appointment( + body: AppointmentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).book(tenant_id, body, actor=user) + + +@appointments_router.get("", response_model=AppointmentListResponse) +async def list_appointments( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: AppointmentListSpec = Depends(_appointment_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + items, total = await AppointmentQueries(db).list_appointments( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return AppointmentListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@appointments_router.get("/{appointment_id}", response_model=AppointmentRead) +async def get_appointment( + appointment_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + return await AppointmentQueries(db).get_appointment(tenant_id, appointment_id) + + +@appointments_router.post("/{appointment_id}/confirm", response_model=AppointmentRead) +async def confirm_appointment( + appointment_id: UUID, + body: AppointmentLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).confirm(tenant_id, appointment_id, body, actor=user) + + +@appointments_router.post("/{appointment_id}/cancel", response_model=AppointmentRead) +async def cancel_appointment( + appointment_id: UUID, + body: AppointmentLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).cancel(tenant_id, appointment_id, body, actor=user) + + +@appointments_router.post("/{appointment_id}/reschedule", response_model=AppointmentRead) +async def reschedule_appointment( + appointment_id: UUID, + body: AppointmentReschedule, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).reschedule(tenant_id, appointment_id, body, actor=user) + + +@appointments_router.post("/{appointment_id}/no-show", response_model=AppointmentRead) +async def mark_no_show( + appointment_id: UUID, + body: AppointmentLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).mark_no_show(tenant_id, appointment_id, body, actor=user) + + +@waiting_list_router.post("", response_model=WaitingListEntryRead, status_code=status.HTTP_201_CREATED) +async def create_waiting_list_entry( + body: WaitingListEntryCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_MANAGE)), +): + return await AppointmentCommands(db).create_waiting_list(tenant_id, body, actor=user) + + +@waiting_list_router.get("", response_model=list[WaitingListEntryRead]) +async def list_waiting_list( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + return await AppointmentQueries(db).list_waiting_list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/healthcare/app/api/v1/audit.py b/backend/services/healthcare/app/api/v1/audit.py new file mode 100644 index 0000000..cac4cb4 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/audit.py @@ -0,0 +1,30 @@ +"""Delivery audit APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import AUDIT_VIEW +from app.schemas.foundation import HealthcareAuditLogRead +from app.services.audit_service import AuditService +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("", response_model=list[HealthcareAuditLogRead]) +async def list_audit( + entity_type: str = Query(...), + entity_id: UUID = Query(...), + limit: int = Query(default=100, ge=1, le=500), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(AUDIT_VIEW)), +): + return await AuditService(db).list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/healthcare/app/api/v1/branches.py b/backend/services/healthcare/app/api/v1/branches.py new file mode 100644 index 0000000..9bb0ec8 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/branches.py @@ -0,0 +1,75 @@ +"""Healthcare branch APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + BRANCHES_CREATE, + BRANCHES_DELETE, + BRANCHES_UPDATE, + BRANCHES_VIEW, +) +from app.schemas.foundation import BranchCreate, BranchRead, BranchUpdate +from app.services.foundation import BranchService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=BranchRead, status_code=status.HTTP_201_CREATED) +async def create_branch( + body: BranchCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BRANCHES_CREATE)), +): + return await BranchService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[BranchRead]) +async def list_branches( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BRANCHES_VIEW)), +): + return await BranchService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{branch_id}", response_model=BranchRead) +async def get_branch( + branch_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BRANCHES_VIEW)), +): + return await BranchService(db).get(tenant_id, branch_id) + + +@router.patch("/{branch_id}", response_model=BranchRead) +async def update_branch( + branch_id: UUID, + body: BranchUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BRANCHES_UPDATE)), +): + return await BranchService(db).update(tenant_id, branch_id, body, actor=user) + + +@router.post("/{branch_id}/delete", response_model=BranchRead) +async def soft_delete_branch( + branch_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BRANCHES_DELETE)), +): + return await BranchService(db).soft_delete(tenant_id, branch_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/clinic_management.py b/backend/services/healthcare/app/api/v1/clinic_management.py new file mode 100644 index 0000000..21aa38d --- /dev/null +++ b/backend/services/healthcare/app/api/v1/clinic_management.py @@ -0,0 +1,119 @@ +"""Clinic management APIs — Phase 13.3.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.clinic_management import ClinicManagementCommands, ClinicManagementQueries +from app.permissions.definitions import CLINIC_ADMIN_MANAGE, CLINIC_ADMIN_VIEW, CLINIC_SERVICES_MANAGE +from app.schemas.clinic_management import ( + ClinicPolicyCreate, + ClinicPolicyRead, + ClinicServiceCreate, + ClinicServiceRead, + OperatingHoursPolicyCreate, + OperatingHoursPolicyRead, + StaffAssignmentCreate, + StaffAssignmentRead, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +services_router = APIRouter() +staff_router = APIRouter() +policies_router = APIRouter() +hours_router = APIRouter() + + +@services_router.post("", response_model=ClinicServiceRead, status_code=status.HTTP_201_CREATED) +async def create_clinic_service( + body: ClinicServiceCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINIC_SERVICES_MANAGE)), +): + return await ClinicManagementCommands(db).create_service(tenant_id, body, actor=user) + + +@services_router.get("", response_model=list[ClinicServiceRead]) +async def list_clinic_services( + clinic_id: UUID | None = Query(default=None), + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_VIEW)), +): + return await ClinicManagementQueries(db).list_services( + tenant_id, clinic_id=clinic_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@staff_router.post("", response_model=StaffAssignmentRead, status_code=status.HTTP_201_CREATED) +async def create_staff_assignment( + body: StaffAssignmentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_MANAGE)), +): + return await ClinicManagementCommands(db).create_staff(tenant_id, body, actor=user) + + +@staff_router.get("", response_model=list[StaffAssignmentRead]) +async def list_staff_assignments( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_VIEW)), +): + return await ClinicManagementQueries(db).list_staff( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@policies_router.post("", response_model=ClinicPolicyRead, status_code=status.HTTP_201_CREATED) +async def create_clinic_policy( + body: ClinicPolicyCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_MANAGE)), +): + return await ClinicManagementCommands(db).create_policy(tenant_id, body, actor=user) + + +@policies_router.get("", response_model=list[ClinicPolicyRead]) +async def list_clinic_policies( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_VIEW)), +): + return await ClinicManagementQueries(db).list_policies( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@hours_router.post("", response_model=OperatingHoursPolicyRead, status_code=status.HTTP_201_CREATED) +async def create_operating_hours( + body: OperatingHoursPolicyCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_MANAGE)), +): + return await ClinicManagementCommands(db).create_hours(tenant_id, body, actor=user) + + +@hours_router.get("", response_model=list[OperatingHoursPolicyRead]) +async def list_operating_hours( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINIC_ADMIN_VIEW)), +): + return await ClinicManagementQueries(db).list_hours( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/healthcare/app/api/v1/clinics.py b/backend/services/healthcare/app/api/v1/clinics.py new file mode 100644 index 0000000..46404d5 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/clinics.py @@ -0,0 +1,75 @@ +"""Healthcare clinic APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + CLINICS_CREATE, + CLINICS_DELETE, + CLINICS_UPDATE, + CLINICS_VIEW, +) +from app.schemas.foundation import ClinicCreate, ClinicRead, ClinicUpdate +from app.services.foundation import ClinicService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ClinicRead, status_code=status.HTTP_201_CREATED) +async def create_clinic( + body: ClinicCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINICS_CREATE)), +): + return await ClinicService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ClinicRead]) +async def list_clinics( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINICS_VIEW)), +): + return await ClinicService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{clinic_id}", response_model=ClinicRead) +async def get_clinic( + clinic_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CLINICS_VIEW)), +): + return await ClinicService(db).get(tenant_id, clinic_id) + + +@router.patch("/{clinic_id}", response_model=ClinicRead) +async def update_clinic( + clinic_id: UUID, + body: ClinicUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINICS_UPDATE)), +): + return await ClinicService(db).update(tenant_id, clinic_id, body, actor=user) + + +@router.post("/{clinic_id}/delete", response_model=ClinicRead) +async def soft_delete_clinic( + clinic_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CLINICS_DELETE)), +): + return await ClinicService(db).soft_delete(tenant_id, clinic_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/configurations.py b/backend/services/healthcare/app/api/v1/configurations.py new file mode 100644 index 0000000..fc2fd1d --- /dev/null +++ b/backend/services/healthcare/app/api/v1/configurations.py @@ -0,0 +1,83 @@ +"""Delivery configuration APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + CONFIGURATIONS_CREATE, + CONFIGURATIONS_DELETE, + CONFIGURATIONS_UPDATE, + CONFIGURATIONS_VIEW, +) +from app.schemas.foundation import ( + HealthcareConfigurationCreate, + HealthcareConfigurationRead, + HealthcareConfigurationUpdate, +) +from app.services.foundation import HealthcareConfigurationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=HealthcareConfigurationRead, status_code=status.HTTP_201_CREATED) +async def create_configuration( + body: HealthcareConfigurationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_CREATE)), +): + return await HealthcareConfigurationService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[HealthcareConfigurationRead]) +async def list_configurations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)), +): + return await HealthcareConfigurationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{configuration_id}", response_model=HealthcareConfigurationRead) +async def get_configuration( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)), +): + return await HealthcareConfigurationService(db).get(tenant_id, configuration_id) + + +@router.patch("/{configuration_id}", response_model=HealthcareConfigurationRead) +async def update_configuration( + configuration_id: UUID, + body: HealthcareConfigurationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_UPDATE)), +): + return await HealthcareConfigurationService(db).update( + tenant_id, configuration_id, body, actor=user + ) + + +@router.post("/{configuration_id}/delete", response_model=HealthcareConfigurationRead) +async def soft_delete_configuration( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_DELETE)), +): + return await HealthcareConfigurationService(db).soft_delete( + tenant_id, configuration_id, actor=user + ) diff --git a/backend/services/healthcare/app/api/v1/delivery_integration.py b/backend/services/healthcare/app/api/v1/delivery_integration.py new file mode 100644 index 0000000..b1586dd --- /dev/null +++ b/backend/services/healthcare/app/api/v1/delivery_integration.py @@ -0,0 +1,90 @@ +"""Delivery integration APIs — Phase 13.7.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + DELIVERY_INTEGRATION_MANAGE, + DELIVERY_INTEGRATION_SUBMIT, + DELIVERY_INTEGRATION_VIEW, +) +from app.schemas.delivery_integration import ( + DeliveryIntegrationCreate, + DeliveryIntegrationRead, + DeliveryJobIntentCreate, + DeliveryJobIntentRead, + DeliveryStatusWebhook, +) +from app.services.delivery_integration import DeliveryIntegrationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +integrations_router = APIRouter() +intents_router = APIRouter() +webhooks_router = APIRouter() + + +@integrations_router.post("", response_model=DeliveryIntegrationRead, status_code=status.HTTP_201_CREATED) +async def register_delivery_integration( + body: DeliveryIntegrationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DELIVERY_INTEGRATION_MANAGE)), +): + return await DeliveryIntegrationService(db).register(tenant_id, body, actor=user) + + +@integrations_router.get("", response_model=list[DeliveryIntegrationRead]) +async def list_delivery_integrations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DELIVERY_INTEGRATION_VIEW)), +): + return await DeliveryIntegrationService(db).list_registrations( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@intents_router.get("", response_model=list[DeliveryJobIntentRead]) +async def list_delivery_job_intents( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DELIVERY_INTEGRATION_VIEW)), +): + return await DeliveryIntegrationService(db).list_intents( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@intents_router.post( + "/prescription-orders/{prescription_order_id}", + response_model=DeliveryJobIntentRead, + status_code=status.HTTP_201_CREATED, +) +async def create_delivery_intent( + prescription_order_id: UUID, + body: DeliveryJobIntentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DELIVERY_INTEGRATION_SUBMIT)), +): + return await DeliveryIntegrationService(db).create_intent_for_order( + tenant_id, prescription_order_id, body, actor=user + ) + + +@webhooks_router.post("/delivery-status", response_model=DeliveryJobIntentRead) +async def delivery_status_webhook( + body: DeliveryStatusWebhook, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), +): + return await DeliveryIntegrationService(db).apply_status_webhook(tenant_id, body) diff --git a/backend/services/healthcare/app/api/v1/departments.py b/backend/services/healthcare/app/api/v1/departments.py new file mode 100644 index 0000000..8f1b589 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/departments.py @@ -0,0 +1,75 @@ +"""Healthcare department APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + DEPARTMENTS_CREATE, + DEPARTMENTS_DELETE, + DEPARTMENTS_UPDATE, + DEPARTMENTS_VIEW, +) +from app.schemas.foundation import DepartmentCreate, DepartmentRead, DepartmentUpdate +from app.services.foundation import DepartmentService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DepartmentRead, status_code=status.HTTP_201_CREATED) +async def create_department( + body: DepartmentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DEPARTMENTS_CREATE)), +): + return await DepartmentService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DepartmentRead]) +async def list_departments( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DEPARTMENTS_VIEW)), +): + return await DepartmentService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{department_id}", response_model=DepartmentRead) +async def get_department( + department_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DEPARTMENTS_VIEW)), +): + return await DepartmentService(db).get(tenant_id, department_id) + + +@router.patch("/{department_id}", response_model=DepartmentRead) +async def update_department( + department_id: UUID, + body: DepartmentUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DEPARTMENTS_UPDATE)), +): + return await DepartmentService(db).update(tenant_id, department_id, body, actor=user) + + +@router.post("/{department_id}/delete", response_model=DepartmentRead) +async def soft_delete_department( + department_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DEPARTMENTS_DELETE)), +): + return await DepartmentService(db).soft_delete(tenant_id, department_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/doctor_panel.py b/backend/services/healthcare/app/api/v1/doctor_panel.py new file mode 100644 index 0000000..541f51f --- /dev/null +++ b/backend/services/healthcare/app/api/v1/doctor_panel.py @@ -0,0 +1,183 @@ +"""Doctor panel APIs — Phase 13.2.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.doctor_panel import DoctorPanelCommands +from app.models.types import VisitSessionStatus +from app.permissions.definitions import ( + DOCTOR_PANEL_VIEW, + VISIT_NOTES_MANAGE, + VISIT_SESSIONS_MANAGE, +) +from app.queries.doctor_panel import DoctorPanelQueries +from app.schemas.doctor_panel import ( + DoctorDashboardRead, + DoctorQueueRead, + VisitNoteCreate, + VisitNoteRead, + VisitNoteUpdate, + VisitSessionActionRequest, + VisitSessionCreate, + VisitSessionRead, +) +from app.specifications.doctor_panel import VisitSessionListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +doctor_panel_router = APIRouter() +visit_sessions_router = APIRouter() +visit_notes_router = APIRouter() + + +def _visit_spec( + doctor_id: UUID | None = Query(default=None), + clinic_id: UUID | None = Query(default=None), + status_filter: VisitSessionStatus | None = Query(default=None, alias="status"), +) -> VisitSessionListSpec: + return VisitSessionListSpec( + doctor_id=doctor_id, + clinic_id=clinic_id, + status=status_filter, + ) + + +@doctor_panel_router.get("/dashboard", response_model=DoctorDashboardRead) +async def doctor_dashboard( + doctor_id: UUID = Query(...), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).dashboard(tenant_id, doctor_id=doctor_id) + + +@doctor_panel_router.get("/queue", response_model=DoctorQueueRead) +async def doctor_queue( + doctor_id: UUID = Query(...), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).queue(tenant_id, doctor_id=doctor_id) + + +@visit_sessions_router.post("", response_model=VisitSessionRead, status_code=status.HTTP_201_CREATED) +async def create_visit_session( + body: VisitSessionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).create_session(tenant_id, body, actor=user) + + +@visit_sessions_router.get("", response_model=list[VisitSessionRead]) +async def list_visit_sessions( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: VisitSessionListSpec = Depends(_visit_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).list_sessions( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + + +@visit_sessions_router.get("/{visit_session_id}", response_model=VisitSessionRead) +async def get_visit_session( + visit_session_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).get_session(tenant_id, visit_session_id) + + +@visit_sessions_router.post("/{visit_session_id}/start", response_model=VisitSessionRead) +async def start_visit_session( + visit_session_id: UUID, + body: VisitSessionActionRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).start_session( + tenant_id, visit_session_id, body, actor=user + ) + + +@visit_sessions_router.post("/{visit_session_id}/finish", response_model=VisitSessionRead) +async def finish_visit_session( + visit_session_id: UUID, + body: VisitSessionActionRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).finish_session( + tenant_id, visit_session_id, body, actor=user + ) + + +@visit_sessions_router.post("/{visit_session_id}/defer", response_model=VisitSessionRead) +async def defer_visit_session( + visit_session_id: UUID, + body: VisitSessionActionRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).defer_session( + tenant_id, visit_session_id, body, actor=user + ) + + +@visit_sessions_router.post("/{visit_session_id}/call-next", response_model=VisitSessionRead) +async def call_next_visit( + visit_session_id: UUID, + body: VisitSessionActionRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_SESSIONS_MANAGE)), +): + return await DoctorPanelCommands(db).call_next( + tenant_id, visit_session_id, body, actor=user + ) + + +@visit_notes_router.post("", response_model=VisitNoteRead, status_code=status.HTTP_201_CREATED) +async def create_visit_note( + body: VisitNoteCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_NOTES_MANAGE)), +): + return await DoctorPanelCommands(db).create_note(tenant_id, body, actor=user) + + +@visit_notes_router.get("", response_model=list[VisitNoteRead]) +async def list_visit_notes( + visit_session_id: UUID = Query(...), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTOR_PANEL_VIEW)), +): + return await DoctorPanelQueries(db).list_notes(tenant_id, visit_session_id) + + +@visit_notes_router.patch("/{note_id}", response_model=VisitNoteRead) +async def update_visit_note( + note_id: UUID, + body: VisitNoteUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(VISIT_NOTES_MANAGE)), +): + return await DoctorPanelCommands(db).update_note(tenant_id, note_id, body, actor=user) diff --git a/backend/services/healthcare/app/api/v1/doctors.py b/backend/services/healthcare/app/api/v1/doctors.py new file mode 100644 index 0000000..a59502f --- /dev/null +++ b/backend/services/healthcare/app/api/v1/doctors.py @@ -0,0 +1,75 @@ +"""Healthcare doctor APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + DOCTORS_CREATE, + DOCTORS_DELETE, + DOCTORS_UPDATE, + DOCTORS_VIEW, +) +from app.schemas.profiles import DoctorCreate, DoctorRead, DoctorUpdate +from app.services.profiles import DoctorService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DoctorRead, status_code=status.HTTP_201_CREATED) +async def create_doctor( + body: DoctorCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DOCTORS_CREATE)), +): + return await DoctorService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DoctorRead]) +async def list_doctors( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTORS_VIEW)), +): + return await DoctorService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{doctor_id}", response_model=DoctorRead) +async def get_doctor( + doctor_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOCTORS_VIEW)), +): + return await DoctorService(db).get(tenant_id, doctor_id) + + +@router.patch("/{doctor_id}", response_model=DoctorRead) +async def update_doctor( + doctor_id: UUID, + body: DoctorUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DOCTORS_UPDATE)), +): + return await DoctorService(db).update(tenant_id, doctor_id, body, actor=user) + + +@router.post("/{doctor_id}/delete", response_model=DoctorRead) +async def soft_delete_doctor( + doctor_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DOCTORS_DELETE)), +): + return await DoctorService(db).soft_delete(tenant_id, doctor_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/external_providers.py b/backend/services/healthcare/app/api/v1/external_providers.py new file mode 100644 index 0000000..f1f2e90 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/external_providers.py @@ -0,0 +1,83 @@ +"""External provider config APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + EXTERNAL_PROVIDERS_CREATE, + EXTERNAL_PROVIDERS_DELETE, + EXTERNAL_PROVIDERS_UPDATE, + EXTERNAL_PROVIDERS_VIEW, +) +from app.schemas.foundation import ( + ExternalProviderConfigCreate, + ExternalProviderConfigRead, + ExternalProviderConfigUpdate, +) +from app.services.foundation import ExternalProviderConfigService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ExternalProviderConfigRead, status_code=status.HTTP_201_CREATED) +async def create_provider( + body: ExternalProviderConfigCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_CREATE)), +): + return await ExternalProviderConfigService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExternalProviderConfigRead]) +async def list_providers( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)), +): + return await ExternalProviderConfigService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{provider_id}", response_model=ExternalProviderConfigRead) +async def get_provider( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)), +): + return await ExternalProviderConfigService(db).get(tenant_id, provider_id) + + +@router.patch("/{provider_id}", response_model=ExternalProviderConfigRead) +async def update_provider( + provider_id: UUID, + body: ExternalProviderConfigUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_UPDATE)), +): + return await ExternalProviderConfigService(db).update( + tenant_id, provider_id, body, actor=user + ) + + +@router.post("/{provider_id}/delete", response_model=ExternalProviderConfigRead) +async def soft_delete_provider( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_DELETE)), +): + return await ExternalProviderConfigService(db).soft_delete( + tenant_id, provider_id, actor=user + ) diff --git a/backend/services/healthcare/app/api/v1/health.py b/backend/services/healthcare/app/api/v1/health.py new file mode 100644 index 0000000..4e033c3 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/health.py @@ -0,0 +1,90 @@ +from fastapi import APIRouter + +from app import __version__ +from app.core.config import settings + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + return { + "status": "ok", + "service": settings.service_name, + "version": __version__, + } + + +@router.get("/capabilities") +async def capabilities(): + return { + "service": settings.service_name, + "version": __version__, + "phase": "13.7", + "commercial_product": "Torbat Healthcare", + "features": { + "foundation": True, + "health": True, + "capabilities": True, + "metrics": True, + "external_providers_ready": True, + "clinics": True, + "branches": True, + "departments": True, + "doctors": True, + "patients": True, + "appointments": True, + "schedules": True, + "availability": True, + "waiting_list": True, + "visits": True, + "doctor_panel": True, + "clinic_management": True, + "patient_portal": True, + "medical_records": True, + "pharmacy": True, + "delivery_integration": True, + "ai": False, + }, + "independence": { + "standalone": True, + "superapp": True, + "integrations": [ + "accounting", + "communication", + "loyalty", + "crm", + "delivery", + "storage", + "identity", + "ai", + ], + "integration_mode": "api_and_events_only", + }, + } + + +@router.get("/metrics") +async def metrics(): + return { + "service": settings.service_name, + "version": __version__, + "phase": "13.7", + "metrics": { + "clinics": "tenant_scoped", + "branches": "tenant_scoped", + "departments": "tenant_scoped", + "doctors": "tenant_scoped", + "patients": "tenant_scoped", + "external_providers": "tenant_scoped", + "configurations": "tenant_scoped", + "settings": "tenant_scoped", + "outbox_events": "tenant_scoped", + "appointments": "tenant_scoped", + "visits": "tenant_scoped", + "encounters": "tenant_scoped", + "pharmacy_orders": "tenant_scoped", + "delivery_job_intents": "tenant_scoped", + }, + "note": "Healthcare phases 13.0–13.7 complete", + } diff --git a/backend/services/healthcare/app/api/v1/medical_records.py b/backend/services/healthcare/app/api/v1/medical_records.py new file mode 100644 index 0000000..35fb96b --- /dev/null +++ b/backend/services/healthcare/app/api/v1/medical_records.py @@ -0,0 +1,151 @@ +"""Medical record APIs — Phase 13.5.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ENCOUNTERS_MANAGE, MEDICAL_RECORDS_MANAGE, MEDICAL_RECORDS_VIEW +from app.schemas.medical_records import ( + AllergyCreate, + AllergyRead, + ConditionCreate, + ConditionRead, + EncounterCreate, + EncounterFinalize, + EncounterRead, + EncounterUpdate, + ImmunizationCreate, + ImmunizationRead, + MedicalRecordAccessRequest, + MedicalRecordCreate, + MedicalRecordRead, + MedicationCreate, + MedicationRead, +) +from app.services.medical_records import MedicalRecordService +from shared.security import CurrentUser + +router = APIRouter() +records_router = APIRouter() +encounters_router = APIRouter() + + +class ClinicalListsResponse(BaseModel): + conditions: list[ConditionRead] + allergies: list[AllergyRead] + medications: list[MedicationRead] + immunizations: list[ImmunizationRead] + + +@records_router.post("", response_model=MedicalRecordRead, status_code=status.HTTP_201_CREATED) +async def create_medical_record( + body: MedicalRecordCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).create_record(tenant_id, body, actor=user) + + +@records_router.get("/{record_id}", response_model=MedicalRecordRead) +async def get_medical_record( + record_id: UUID, + reason: str | None = Query(default=None, max_length=500), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_VIEW)), +): + access = MedicalRecordAccessRequest(reason=reason) + return await MedicalRecordService(db).get_record(tenant_id, record_id, actor=user, access=access) + + +@records_router.get("/{record_id}/clinical-lists", response_model=ClinicalListsResponse) +async def get_clinical_lists( + record_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_VIEW)), +): + data = await MedicalRecordService(db).list_clinical_lists(tenant_id, record_id) + return ClinicalListsResponse(**data) + + +@records_router.post("/{record_id}/conditions", response_model=ConditionRead, status_code=status.HTTP_201_CREATED) +async def add_condition( + record_id: UUID, + body: ConditionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).add_condition(tenant_id, record_id, body, actor=user) + + +@records_router.post("/{record_id}/allergies", response_model=AllergyRead, status_code=status.HTTP_201_CREATED) +async def add_allergy( + record_id: UUID, + body: AllergyCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).add_allergy(tenant_id, record_id, body, actor=user) + + +@records_router.post("/{record_id}/medications", response_model=MedicationRead, status_code=status.HTTP_201_CREATED) +async def add_medication( + record_id: UUID, + body: MedicationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).add_medication(tenant_id, record_id, body, actor=user) + + +@records_router.post("/{record_id}/immunizations", response_model=ImmunizationRead, status_code=status.HTTP_201_CREATED) +async def add_immunization( + record_id: UUID, + body: ImmunizationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDICAL_RECORDS_MANAGE)), +): + return await MedicalRecordService(db).add_immunization(tenant_id, record_id, body, actor=user) + + +@encounters_router.post("", response_model=EncounterRead, status_code=status.HTTP_201_CREATED) +async def create_encounter( + body: EncounterCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ENCOUNTERS_MANAGE)), +): + return await MedicalRecordService(db).create_encounter(tenant_id, body, actor=user) + + +@encounters_router.patch("/{encounter_id}", response_model=EncounterRead) +async def update_encounter( + encounter_id: UUID, + body: EncounterUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ENCOUNTERS_MANAGE)), +): + return await MedicalRecordService(db).update_encounter(tenant_id, encounter_id, body, actor=user) + + +@encounters_router.post("/{encounter_id}/finalize", response_model=EncounterRead) +async def finalize_encounter( + encounter_id: UUID, + body: EncounterFinalize, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ENCOUNTERS_MANAGE)), +): + return await MedicalRecordService(db).finalize_encounter(tenant_id, encounter_id, body, actor=user) diff --git a/backend/services/healthcare/app/api/v1/patient_portal.py b/backend/services/healthcare/app/api/v1/patient_portal.py new file mode 100644 index 0000000..fe53aab --- /dev/null +++ b/backend/services/healthcare/app/api/v1/patient_portal.py @@ -0,0 +1,128 @@ +"""Patient portal APIs — Phase 13.4.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.api.patient_deps import require_patient_context +from app.api.permissions import require_permissions +from app.permissions.definitions import PATIENT_PORTAL_MANAGE, PATIENT_PORTAL_VIEW +from app.schemas.appointments import AppointmentListResponse, AppointmentRead +from app.schemas.patient_portal import ( + PatientDocumentRefCreate, + PatientDocumentRefRead, + PatientNotificationPreferenceRead, + PatientNotificationPreferenceUpsert, + PatientPortalAppointmentBook, + PatientPortalAppointmentCancel, + PatientPortalProfileRead, + PatientPortalProfileUpdate, +) +from app.services.patient_portal import PatientPortalService +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("/me", response_model=PatientPortalProfileRead) +async def get_my_profile( + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_VIEW)), +): + return await PatientPortalService(db).get_or_create_profile(tenant_id, patient_id, actor=user) + + +@router.patch("/me", response_model=PatientPortalProfileRead) +async def update_my_profile( + body: PatientPortalProfileUpdate, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).update_profile(tenant_id, patient_id, body, actor=user) + + +@router.get("/appointments", response_model=AppointmentListResponse) +async def list_my_appointments( + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_VIEW)), +): + items, total = await PatientPortalService(db).list_appointments(tenant_id, patient_id) + return AppointmentListResponse(items=items, total=total, page=1, page_size=50) + + +@router.post("/appointments", response_model=AppointmentRead, status_code=status.HTTP_201_CREATED) +async def book_my_appointment( + body: PatientPortalAppointmentBook, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).book_appointment(tenant_id, patient_id, body, actor=user) + + +@router.post("/appointments/{appointment_id}/cancel", response_model=AppointmentRead) +async def cancel_my_appointment( + appointment_id: UUID, + body: PatientPortalAppointmentCancel, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).cancel_appointment( + tenant_id, patient_id, appointment_id, body, actor=user + ) + + +@router.get("/documents", response_model=list[PatientDocumentRefRead]) +async def list_my_documents( + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_VIEW)), +): + return await PatientPortalService(db).list_documents(tenant_id, patient_id) + + +@router.post("/documents", response_model=PatientDocumentRefRead, status_code=status.HTTP_201_CREATED) +async def create_my_document( + body: PatientDocumentRefCreate, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).create_document(tenant_id, patient_id, body, actor=user) + + +@router.get("/notification-preferences", response_model=list[PatientNotificationPreferenceRead]) +async def list_my_notification_prefs( + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_VIEW)), +): + return await PatientPortalService(db).list_notification_prefs(tenant_id, patient_id) + + +@router.put("/notification-preferences", response_model=PatientNotificationPreferenceRead) +async def upsert_my_notification_pref( + body: PatientNotificationPreferenceUpsert, + patient_id: UUID = Depends(require_patient_context), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENT_PORTAL_MANAGE)), +): + return await PatientPortalService(db).upsert_notification_pref( + tenant_id, patient_id, body, actor=user + ) diff --git a/backend/services/healthcare/app/api/v1/patients.py b/backend/services/healthcare/app/api/v1/patients.py new file mode 100644 index 0000000..e18eea8 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/patients.py @@ -0,0 +1,75 @@ +"""Healthcare patient APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + PATIENTS_CREATE, + PATIENTS_DELETE, + PATIENTS_UPDATE, + PATIENTS_VIEW, +) +from app.schemas.profiles import PatientCreate, PatientRead, PatientUpdate +from app.services.profiles import PatientService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=PatientRead, status_code=status.HTTP_201_CREATED) +async def create_patient( + body: PatientCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENTS_CREATE)), +): + return await PatientService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[PatientRead]) +async def list_patients( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENTS_VIEW)), +): + return await PatientService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{patient_id}", response_model=PatientRead) +async def get_patient( + patient_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PATIENTS_VIEW)), +): + return await PatientService(db).get(tenant_id, patient_id) + + +@router.patch("/{patient_id}", response_model=PatientRead) +async def update_patient( + patient_id: UUID, + body: PatientUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENTS_UPDATE)), +): + return await PatientService(db).update(tenant_id, patient_id, body, actor=user) + + +@router.post("/{patient_id}/delete", response_model=PatientRead) +async def soft_delete_patient( + patient_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PATIENTS_DELETE)), +): + return await PatientService(db).soft_delete(tenant_id, patient_id, actor=user) diff --git a/backend/services/healthcare/app/api/v1/permissions.py b/backend/services/healthcare/app/api/v1/permissions.py new file mode 100644 index 0000000..f78e1fd --- /dev/null +++ b/backend/services/healthcare/app/api/v1/permissions.py @@ -0,0 +1,27 @@ +"""Permission catalog discovery API — Phase 13.0.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + ALL_PERMISSIONS, + HEALTHCARE_VIEW, + PERMISSION_PREFIXES, +) +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("/catalog") +async def permission_catalog( + _user: CurrentUser = Depends(require_permissions(HEALTHCARE_VIEW)), +): + """Static permission discovery for Identity / admin consoles.""" + return { + "prefix": "healthcare.", + "prefixes": list(PERMISSION_PREFIXES), + "permissions": list(ALL_PERMISSIONS), + "count": len(ALL_PERMISSIONS), + } diff --git a/backend/services/healthcare/app/api/v1/pharmacy.py b/backend/services/healthcare/app/api/v1/pharmacy.py new file mode 100644 index 0000000..b96e8c6 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/pharmacy.py @@ -0,0 +1,138 @@ +"""Pharmacy network APIs — Phase 13.6.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + PHARMACIES_MANAGE, + PHARMACY_VIEW, + PRESCRIPTION_ORDERS_FULFILL, + PRESCRIPTIONS_SUBMIT, +) +from app.schemas.pharmacy import ( + PharmacyCreate, + PharmacyRead, + PrescriptionLifecycleRequest, + PrescriptionOrderCreate, + PrescriptionOrderRead, +) +from app.services.pharmacy import PharmacyService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +pharmacies_router = APIRouter() +prescription_orders_router = APIRouter() + + +@pharmacies_router.post("", response_model=PharmacyRead, status_code=status.HTTP_201_CREATED) +async def create_pharmacy( + body: PharmacyCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PHARMACIES_MANAGE)), +): + return await PharmacyService(db).create_pharmacy(tenant_id, body, actor=user) + + +@pharmacies_router.get("", response_model=list[PharmacyRead]) +async def list_pharmacies( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PHARMACY_VIEW)), +): + return await PharmacyService(db).list_pharmacies( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@prescription_orders_router.post("", response_model=PrescriptionOrderRead, status_code=status.HTTP_201_CREATED) +async def submit_prescription_order( + body: PrescriptionOrderCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTIONS_SUBMIT)), +): + return await PharmacyService(db).submit_order(tenant_id, body, actor=user) + + +@prescription_orders_router.get("", response_model=list[PrescriptionOrderRead]) +async def list_prescription_orders( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PHARMACY_VIEW)), +): + return await PharmacyService(db).list_orders( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@prescription_orders_router.get("/{order_id}", response_model=PrescriptionOrderRead) +async def get_prescription_order( + order_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PHARMACY_VIEW)), +): + return await PharmacyService(db).get_order(tenant_id, order_id) + + +@prescription_orders_router.post("/{order_id}/accept", response_model=PrescriptionOrderRead) +async def accept_prescription( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).accept(tenant_id, order_id, body, actor=user) + + +@prescription_orders_router.post("/{order_id}/prepare", response_model=PrescriptionOrderRead) +async def prepare_prescription( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).prepare(tenant_id, order_id, body, actor=user) + + +@prescription_orders_router.post("/{order_id}/mark-ready", response_model=PrescriptionOrderRead) +async def mark_prescription_ready( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).mark_ready(tenant_id, order_id, body, actor=user) + + +@prescription_orders_router.post("/{order_id}/complete-pickup", response_model=PrescriptionOrderRead) +async def complete_prescription_pickup( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).complete_pickup(tenant_id, order_id, body, actor=user) + + +@prescription_orders_router.post("/{order_id}/cancel", response_model=PrescriptionOrderRead) +async def cancel_prescription( + order_id: UUID, + body: PrescriptionLifecycleRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PRESCRIPTION_ORDERS_FULFILL)), +): + return await PharmacyService(db).cancel(tenant_id, order_id, body, actor=user) diff --git a/backend/services/healthcare/app/api/v1/settings.py b/backend/services/healthcare/app/api/v1/settings.py new file mode 100644 index 0000000..a2eb5f6 --- /dev/null +++ b/backend/services/healthcare/app/api/v1/settings.py @@ -0,0 +1,39 @@ +"""Delivery settings APIs — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import SETTINGS_MANAGE, SETTINGS_VIEW +from app.schemas.foundation import HealthcareSettingRead, HealthcareSettingUpsert +from app.services.foundation import HealthcareSettingService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.put("", response_model=HealthcareSettingRead, status_code=status.HTTP_200_OK) +async def upsert_setting( + body: HealthcareSettingUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SETTINGS_MANAGE)), +): + return await HealthcareSettingService(db).upsert(tenant_id, body, actor=user) + + +@router.get("", response_model=list[HealthcareSettingRead]) +async def list_settings( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SETTINGS_VIEW)), +): + return await HealthcareSettingService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/healthcare/app/commands/__init__.py b/backend/services/healthcare/app/commands/__init__.py new file mode 100644 index 0000000..99fbbe3 --- /dev/null +++ b/backend/services/healthcare/app/commands/__init__.py @@ -0,0 +1,4 @@ +"""Profile commands package.""" +from app.commands.profiles import ProfileCommands + +__all__ = ["ProfileCommands"] diff --git a/backend/services/healthcare/app/commands/appointments.py b/backend/services/healthcare/app/commands/appointments.py new file mode 100644 index 0000000..53d197b --- /dev/null +++ b/backend/services/healthcare/app/commands/appointments.py @@ -0,0 +1,72 @@ +"""Appointment command handlers — Phase 13.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.appointments import ( + AppointmentCreate, + AppointmentLifecycleRequest, + AppointmentReschedule, + AppointmentTypeCreate, + AvailabilityWindowCreate, + ScheduleCreate, + WaitingListEntryCreate, +) +from app.services.appointments import ( + AppointmentService, + AppointmentTypeService, + AvailabilityWindowService, + ScheduleService, + WaitingListService, +) +from shared.security import CurrentUser + + +class AppointmentCommands: + def __init__(self, session: AsyncSession) -> None: + self.schedules = ScheduleService(session) + self.availability = AvailabilityWindowService(session) + self.types = AppointmentTypeService(session) + self.appointments = AppointmentService(session) + self.waiting_list = WaitingListService(session) + + async def create_schedule(self, tenant_id: UUID, body: ScheduleCreate, *, actor: CurrentUser | None): + return await self.schedules.create(tenant_id, body, actor=actor) + + async def create_availability( + self, tenant_id: UUID, body: AvailabilityWindowCreate, *, actor: CurrentUser | None + ): + return await self.availability.create(tenant_id, body, actor=actor) + + async def create_type(self, tenant_id: UUID, body: AppointmentTypeCreate, *, actor: CurrentUser | None): + return await self.types.create(tenant_id, body, actor=actor) + + async def book(self, tenant_id: UUID, body: AppointmentCreate, *, actor: CurrentUser | None): + return await self.appointments.book(tenant_id, body, actor=actor) + + async def confirm( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor + ): + return await self.appointments.confirm(tenant_id, appointment_id, body, actor=actor) + + async def cancel( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor + ): + return await self.appointments.cancel(tenant_id, appointment_id, body, actor=actor) + + async def reschedule( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentReschedule, *, actor + ): + return await self.appointments.reschedule(tenant_id, appointment_id, body, actor=actor) + + async def mark_no_show( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor + ): + return await self.appointments.mark_no_show(tenant_id, appointment_id, body, actor=actor) + + async def create_waiting_list( + self, tenant_id: UUID, body: WaitingListEntryCreate, *, actor: CurrentUser | None + ): + return await self.waiting_list.create(tenant_id, body, actor=actor) diff --git a/backend/services/healthcare/app/commands/clinic_management.py b/backend/services/healthcare/app/commands/clinic_management.py new file mode 100644 index 0000000..62963ab --- /dev/null +++ b/backend/services/healthcare/app/commands/clinic_management.py @@ -0,0 +1,51 @@ +"""Clinic management command/query handlers — Phase 13.3.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.clinic_management import ( + ClinicPolicyCreate, + ClinicServiceCreate, + OperatingHoursPolicyCreate, + StaffAssignmentCreate, +) +from app.services.clinic_management import ClinicManagementService +from shared.security import CurrentUser + + +class ClinicManagementCommands: + def __init__(self, session: AsyncSession) -> None: + self.svc = ClinicManagementService(session) + + async def create_service(self, tenant_id: UUID, body: ClinicServiceCreate, *, actor=None): + return await self.svc.create_service(tenant_id, body, actor=actor) + + async def create_staff(self, tenant_id: UUID, body: StaffAssignmentCreate, *, actor=None): + return await self.svc.create_staff_assignment(tenant_id, body, actor=actor) + + async def create_policy(self, tenant_id: UUID, body: ClinicPolicyCreate, *, actor=None): + return await self.svc.create_policy(tenant_id, body, actor=actor) + + async def create_hours(self, tenant_id: UUID, body: OperatingHoursPolicyCreate, *, actor=None): + return await self.svc.create_operating_hours(tenant_id, body, actor=actor) + + +class ClinicManagementQueries: + def __init__(self, session: AsyncSession) -> None: + self.svc = ClinicManagementService(session) + + async def list_services( + self, tenant_id: UUID, *, clinic_id: UUID | None = None, offset: int = 0, limit: int = 20 + ): + return await self.svc.list_services(tenant_id, clinic_id=clinic_id, offset=offset, limit=limit) + + async def list_staff(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.svc.list_staff(tenant_id, offset=offset, limit=limit) + + async def list_policies(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.svc.list_policies(tenant_id, offset=offset, limit=limit) + + async def list_hours(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.svc.list_operating_hours(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/healthcare/app/commands/doctor_panel.py b/backend/services/healthcare/app/commands/doctor_panel.py new file mode 100644 index 0000000..503f5d3 --- /dev/null +++ b/backend/services/healthcare/app/commands/doctor_panel.py @@ -0,0 +1,81 @@ +"""Doctor panel command handlers — Phase 13.2.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.doctor_panel import ( + VisitNoteCreate, + VisitNoteUpdate, + VisitSessionActionRequest, + VisitSessionCreate, +) +from app.services.doctor_panel import VisitNoteService, VisitSessionService +from shared.security import CurrentUser + + +class DoctorPanelCommands: + def __init__(self, session: AsyncSession) -> None: + self.sessions = VisitSessionService(session) + self.notes = VisitNoteService(session) + + async def create_session( + self, tenant_id: UUID, body: VisitSessionCreate, *, actor: CurrentUser | None = None + ): + return await self.sessions.create(tenant_id, body, actor=actor) + + async def start_session( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ): + return await self.sessions.start(tenant_id, visit_session_id, body, actor=actor) + + async def finish_session( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ): + return await self.sessions.finish(tenant_id, visit_session_id, body, actor=actor) + + async def defer_session( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ): + return await self.sessions.defer(tenant_id, visit_session_id, body, actor=actor) + + async def call_next( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ): + return await self.sessions.call_next(tenant_id, visit_session_id, body, actor=actor) + + async def create_note( + self, tenant_id: UUID, body: VisitNoteCreate, *, actor: CurrentUser | None = None + ): + return await self.notes.create(tenant_id, body, actor=actor) + + async def update_note( + self, + tenant_id: UUID, + note_id: UUID, + body: VisitNoteUpdate, + *, + actor: CurrentUser | None = None, + ): + return await self.notes.update(tenant_id, note_id, body, actor=actor) diff --git a/backend/services/healthcare/app/commands/profiles.py b/backend/services/healthcare/app/commands/profiles.py new file mode 100644 index 0000000..b4a56a6 --- /dev/null +++ b/backend/services/healthcare/app/commands/profiles.py @@ -0,0 +1,46 @@ +"""Profile command handlers — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.profiles import DoctorCreate, DoctorUpdate, PatientCreate, PatientUpdate +from app.services.profiles import DoctorService, PatientService +from shared.security import CurrentUser + + +class ProfileCommands: + def __init__(self, session: AsyncSession) -> None: + self.doctors = DoctorService(session) + self.patients = PatientService(session) + + async def create_doctor( + self, tenant_id: UUID, body: DoctorCreate, *, actor: CurrentUser | None + ): + return await self.doctors.create(tenant_id, body, actor=actor) + + async def update_doctor( + self, + tenant_id: UUID, + doctor_id: UUID, + body: DoctorUpdate, + *, + actor: CurrentUser | None, + ): + return await self.doctors.update(tenant_id, doctor_id, body, actor=actor) + + async def create_patient( + self, tenant_id: UUID, body: PatientCreate, *, actor: CurrentUser | None + ): + return await self.patients.create(tenant_id, body, actor=actor) + + async def update_patient( + self, + tenant_id: UUID, + patient_id: UUID, + body: PatientUpdate, + *, + actor: CurrentUser | None, + ): + return await self.patients.update(tenant_id, patient_id, body, actor=actor) diff --git a/backend/services/healthcare/app/core/config.py b/backend/services/healthcare/app/core/config.py new file mode 100644 index 0000000..b8c38ce --- /dev/null +++ b/backend/services/healthcare/app/core/config.py @@ -0,0 +1,75 @@ +"""Healthcare Service settings.""" +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + environment: Literal["development", "staging", "production", "test"] = "development" + debug: bool = True + log_level: str = "INFO" + service_name: str = Field( + default="healthcare-service", validation_alias="HEALTHCARE_SERVICE_NAME" + ) + api_v1_prefix: str = "/api/v1" + + database_url: str = Field( + default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/healthcare_db", + validation_alias="HEALTHCARE_DATABASE_URL", + ) + database_url_sync: str = Field( + default="postgresql+psycopg://superapp:superapp_password@localhost:5432/healthcare_db", + validation_alias="HEALTHCARE_DATABASE_URL_SYNC", + ) + + core_service_url: str = Field( + default="http://localhost:8000", validation_alias="CORE_SERVICE_URL" + ) + + keycloak_enabled: bool = True + keycloak_server_url: str = Field( + default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL" + ) + keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL") + keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM") + + jwt_algorithm: str = "RS256" + jwt_audience: str = "account" + jwt_verify_signature: bool = True + auth_required: bool = True + + cors_origins: str = Field( + default="http://localhost:3000,http://127.0.0.1:3000", + validation_alias="CORS_ORIGINS", + ) + + @property + def keycloak_public_base(self) -> str: + return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/") + + @property + def keycloak_public_realm_url(self) -> str: + return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}" + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/services/healthcare/app/core/database.py b/backend/services/healthcare/app/core/database.py new file mode 100644 index 0000000..e44c490 --- /dev/null +++ b/backend/services/healthcare/app/core/database.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.pool import StaticPool + +from app.core.config import settings + + +class Base(DeclarativeBase): + pass + + +_engine_kwargs: dict = {"pool_pre_ping": True, "future": True} +if settings.database_url.startswith("sqlite"): + _engine_kwargs["poolclass"] = StaticPool + _engine_kwargs["connect_args"] = {"check_same_thread": False} + +engine = create_async_engine(settings.database_url, **_engine_kwargs) +AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise diff --git a/backend/services/healthcare/app/core/logging.py b/backend/services/healthcare/app/core/logging.py new file mode 100644 index 0000000..cc6f49a --- /dev/null +++ b/backend/services/healthcare/app/core/logging.py @@ -0,0 +1,14 @@ +import logging +import sys + + +def configure_logging(level: str = "INFO") -> None: + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + stream=sys.stdout, + ) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/backend/services/healthcare/app/core/security.py b/backend/services/healthcare/app/core/security.py new file mode 100644 index 0000000..4ba748a --- /dev/null +++ b/backend/services/healthcare/app/core/security.py @@ -0,0 +1,49 @@ +"""JWT authentication dependencies for Delivery service.""" +from __future__ import annotations + +from functools import lru_cache + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.config import settings +from shared.auth import JWTSettings, JWTValidator +from shared.exceptions import UnauthorizedError +from shared.security import CurrentUser + +_bearer = HTTPBearer(auto_error=False) + + +@lru_cache +def get_jwt_validator() -> JWTValidator: + return JWTValidator( + JWTSettings( + keycloak_enabled=settings.keycloak_enabled, + keycloak_server_url=settings.keycloak_server_url, + keycloak_realm=settings.keycloak_realm, + jwt_algorithm=settings.jwt_algorithm, + jwt_audience=settings.jwt_audience, + jwt_verify_signature=settings.jwt_verify_signature, + jwt_issuer=settings.keycloak_public_realm_url, + ) + ) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> CurrentUser: + if not settings.auth_required: + return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"]) + if credentials is None or not credentials.credentials: + raise UnauthorizedError("توکن احراز هویت ارائه نشده است") + return await get_jwt_validator().validate(credentials.credentials) + + +async def get_optional_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> CurrentUser | None: + if not settings.auth_required: + return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"]) + if credentials is None or not credentials.credentials: + return None + return await get_jwt_validator().validate(credentials.credentials) diff --git a/backend/services/healthcare/app/events/publisher.py b/backend/services/healthcare/app/events/publisher.py new file mode 100644 index 0000000..fc81061 --- /dev/null +++ b/backend/services/healthcare/app/events/publisher.py @@ -0,0 +1,121 @@ +"""Delivery event publisher — in-memory + transactional outbox (ADR-006).""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.events import EventEnvelope, EventStatus + +from app.core.config import settings +from app.events.types import HealthcareEventType +from app.models.outbox import OutboxEvent + + +class EventPublisher(Protocol): + def publish( + self, + *, + event_type: HealthcareEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: ... + + +class InMemoryEventPublisher: + """Records published envelopes for tests and local verification.""" + + def __init__(self) -> None: + self.published: list[EventEnvelope] = [] + + def publish( + self, + *, + event_type: HealthcareEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: + envelope = EventEnvelope( + event_id=uuid4(), + event_type=event_type.value, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + tenant_id=tenant_id, + source_service=settings.service_name, + payload=payload or {}, + ) + self.published.append(envelope) + return envelope + + def record(self, envelope: EventEnvelope) -> EventEnvelope: + self.published.append(envelope) + return envelope + + +class TransactionalEventPublisher: + """Persist outbox row in the current transaction; mirror to memory in tests.""" + + def __init__( + self, session: AsyncSession, memory: InMemoryEventPublisher | None = None + ) -> None: + self.session = session + if memory is not None: + self.memory = memory + elif settings.environment == "test": + self.memory = get_event_publisher() + else: + self.memory = None + + async def publish( + self, + *, + event_type: HealthcareEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: + envelope = EventEnvelope( + event_id=uuid4(), + event_type=event_type.value, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + tenant_id=tenant_id, + source_service=settings.service_name, + payload=payload or {}, + ) + row = OutboxEvent( + tenant_id=tenant_id, + event_type=envelope.event_type, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + payload={ + "event_id": str(envelope.event_id), + "source_service": settings.service_name, + **(payload or {}), + }, + status=EventStatus.PENDING, + ) + self.session.add(row) + await self.session.flush() + if self.memory is not None: + self.memory.record(envelope) + return envelope + + +_default_publisher = InMemoryEventPublisher() + + +def get_event_publisher() -> InMemoryEventPublisher: + return _default_publisher + + +def reset_event_publisher() -> InMemoryEventPublisher: + global _default_publisher + _default_publisher = InMemoryEventPublisher() + return _default_publisher diff --git a/backend/services/healthcare/app/events/types.py b/backend/services/healthcare/app/events/types.py new file mode 100644 index 0000000..6b0b495 --- /dev/null +++ b/backend/services/healthcare/app/events/types.py @@ -0,0 +1,63 @@ +"""Healthcare event type contracts (publish-only).""" +from __future__ import annotations + +import enum + + +class HealthcareEventType(str, enum.Enum): + CLINIC_CREATED = "healthcare.clinic.created" + CLINIC_UPDATED = "healthcare.clinic.updated" + BRANCH_CREATED = "healthcare.branch.created" + BRANCH_UPDATED = "healthcare.branch.updated" + DEPARTMENT_CREATED = "healthcare.department.created" + DEPARTMENT_UPDATED = "healthcare.department.updated" + DOCTOR_CREATED = "healthcare.doctor.created" + DOCTOR_UPDATED = "healthcare.doctor.updated" + PATIENT_CREATED = "healthcare.patient.created" + PATIENT_UPDATED = "healthcare.patient.updated" + EXTERNAL_PROVIDER_REGISTERED = "healthcare.external_provider.registered" + EXTERNAL_PROVIDER_UPDATED = "healthcare.external_provider.updated" + CONFIGURATION_CREATED = "healthcare.configuration.created" + CONFIGURATION_UPDATED = "healthcare.configuration.updated" + SETTING_UPDATED = "healthcare.setting.updated" + # Phase 13.1 — Appointment Engine + SCHEDULE_CREATED = "healthcare.schedule.created" + APPOINTMENT_BOOKED = "healthcare.appointment.booked" + APPOINTMENT_CONFIRMED = "healthcare.appointment.confirmed" + APPOINTMENT_CANCELLED = "healthcare.appointment.cancelled" + APPOINTMENT_RESCHEDULED = "healthcare.appointment.rescheduled" + APPOINTMENT_NO_SHOW = "healthcare.appointment.no_show" + APPOINTMENT_REMINDER_SCHEDULED = "healthcare.appointment.reminder_scheduled" + APPOINTMENT_CHECKED_IN = "healthcare.appointment.checked_in" + APPOINTMENT_COMPLETED = "healthcare.appointment.completed" + # Phase 13.2 — Doctor Panel + VISIT_SESSION_CREATED = "healthcare.visit_session.created" + VISIT_SESSION_STARTED = "healthcare.visit_session.started" + VISIT_SESSION_FINISHED = "healthcare.visit_session.finished" + VISIT_SESSION_DEFERRED = "healthcare.visit_session.deferred" + VISIT_SESSION_CALLED = "healthcare.visit_session.called" + VISIT_NOTE_CREATED = "healthcare.visit_note.created" + VISIT_NOTE_UPDATED = "healthcare.visit_note.updated" + # Phase 13.3 — Clinic Management + CLINIC_SERVICE_CREATED = "healthcare.clinic_service.created" + STAFF_ASSIGNMENT_CREATED = "healthcare.staff_assignment.created" + CLINIC_POLICY_CREATED = "healthcare.clinic_policy.created" + OPERATING_HOURS_POLICY_CREATED = "healthcare.operating_hours_policy.created" + # Phase 13.4 — Patient Portal + PATIENT_PORTAL_PROFILE_CREATED = "healthcare.patient_portal.profile_created" + PATIENT_PORTAL_PROFILE_UPDATED = "healthcare.patient_portal.profile_updated" + PATIENT_DOCUMENT_REF_CREATED = "healthcare.patient_portal.document_ref_created" + PATIENT_NOTIFICATION_PREF_UPDATED = "healthcare.patient_portal.notification_pref_updated" + # Phase 13.5 — Medical Record + MEDICAL_RECORD_CREATED = "healthcare.medical_record.created" + ENCOUNTER_CREATED = "healthcare.encounter.created" + ENCOUNTER_FINALIZED = "healthcare.encounter.finalized" + MEDICAL_RECORD_ACCESSED = "healthcare.medical_record.accessed" + # Phase 13.6 — Pharmacy Network + PHARMACY_CREATED = "healthcare.pharmacy.created" + PRESCRIPTION_ORDER_SUBMITTED = "healthcare.prescription_order.submitted" + PRESCRIPTION_ORDER_STATUS_CHANGED = "healthcare.prescription_order.status_changed" + # Phase 13.7 — Delivery Integration + DELIVERY_INTEGRATION_REGISTERED = "healthcare.delivery_integration.registered" + DELIVERY_JOB_INTENT_CREATED = "healthcare.delivery_job_intent.created" + DELIVERY_JOB_STATUS_UPDATED = "healthcare.delivery_job_intent.status_updated" diff --git a/backend/services/healthcare/app/main.py b/backend/services/healthcare/app/main.py new file mode 100644 index 0000000..8b35b3c --- /dev/null +++ b/backend/services/healthcare/app/main.py @@ -0,0 +1,139 @@ +from contextlib import asynccontextmanager + + + +from fastapi import FastAPI, Request + +from fastapi.middleware.cors import CORSMiddleware + +from fastapi.responses import JSONResponse + + + +from app import __version__ + +from app.api.v1 import api_router + +from app.api.v1 import health + +from app.core.config import settings + +from app.core.logging import configure_logging, get_logger + +from app.middlewares.tenant import TenantHeaderMiddleware + +from shared.exceptions import AppError + +from shared.responses import ErrorDetail, ErrorResponse + + + +configure_logging(settings.log_level) + +logger = get_logger(__name__) + + + + + +@asynccontextmanager + +async def lifespan(app: FastAPI): + + logger.info("service_starting", extra={"service": settings.service_name}) + + yield + + + + + +def create_app() -> FastAPI: + + app = FastAPI( + + title="Healthcare Platform", + + version=__version__, + + description="Torbat Healthcare — Healthcare Platform (Phase 13.7 Delivery Integration)", + + lifespan=lifespan, + + ) + + app.add_middleware( + + CORSMiddleware, + + allow_origins=settings.cors_origin_list, + + allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir", + + allow_credentials=False, + + allow_methods=["*"], + + allow_headers=["*"], + + ) + + app.add_middleware(TenantHeaderMiddleware) + + + + @app.exception_handler(AppError) + + async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + + return JSONResponse( + + status_code=exc.status_code, + + content=ErrorResponse( + + error=ErrorDetail( + + code=exc.error_code, message=exc.message, details=exc.details + + ) + + ).model_dump(), + + ) + + + + @app.exception_handler(Exception) + + async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse: + + logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc) + + return JSONResponse( + + status_code=500, + + content=ErrorResponse( + + error=ErrorDetail(code="internal_error", message="خطای داخلی سرور") + + ).model_dump(), + + ) + + + + app.include_router(health.router) + + app.include_router(api_router, prefix=settings.api_v1_prefix) + + return app + + + + + +app = create_app() + + diff --git a/backend/services/healthcare/app/middlewares/tenant.py b/backend/services/healthcare/app/middlewares/tenant.py new file mode 100644 index 0000000..4b08726 --- /dev/null +++ b/backend/services/healthcare/app/middlewares/tenant.py @@ -0,0 +1,24 @@ +"""Tenant header resolution middleware.""" +from __future__ import annotations + +from uuid import UUID + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID + + +class TenantHeaderMiddleware(BaseHTTPMiddleware): + """Resolve tenant from X-Tenant-ID header only (microservice pattern).""" + + async def dispatch(self, request: Request, call_next) -> Response: + setattr(request.state, STATE_TENANT_ID, None) + raw = request.headers.get(HEADER_TENANT_ID) + if raw: + try: + setattr(request.state, STATE_TENANT_ID, UUID(raw)) + except ValueError: + pass + return await call_next(request) diff --git a/backend/services/healthcare/app/models/__init__.py b/backend/services/healthcare/app/models/__init__.py new file mode 100644 index 0000000..f0028b7 --- /dev/null +++ b/backend/services/healthcare/app/models/__init__.py @@ -0,0 +1,53 @@ +"""Import all models for Alembic metadata discovery.""" +from app.models.foundation import ( # noqa: F401 + Branch, + Clinic, + Department, + ExternalProviderConfig, + HealthcareAuditLog, + HealthcareConfiguration, + HealthcarePermission, + HealthcareRole, + HealthcareSetting, +) +from app.models.outbox import OutboxEvent # noqa: F401 +from app.models.profiles import Doctor, Patient # noqa: F401 +from app.models.appointments import ( # noqa: F401 + Appointment, + AppointmentType, + AvailabilityWindow, + Schedule, + WaitingListEntry, +) +from app.models.doctor_panel import VisitNote, VisitSession # noqa: F401 +from app.models.clinic_management import ( # noqa: F401 + ClinicPolicy, + ClinicService, + OperatingHoursPolicy, + StaffAssignment, +) +from app.models.patient_portal import ( # noqa: F401 + PatientDocumentRef, + PatientNotificationPreference, + PatientPortalProfile, +) +from app.models.medical_records import ( # noqa: F401 + Allergy, + Condition, + Encounter, + ImmunizationRecord, + MedicalRecord, + MedicalRecordAccessLog, + MedicationStatement, +) +from app.models.pharmacy import ( # noqa: F401 + Pharmacy, + PrescriptionLine, + PrescriptionOrder, + PrescriptionStatusHistory, +) +from app.models.delivery_integration import ( # noqa: F401 + DeliveryIntegrationRegistration, + DeliveryJobIntent, + DeliveryJobStatusSnapshot, +) diff --git a/backend/services/healthcare/app/models/appointments.py b/backend/services/healthcare/app/models/appointments.py new file mode 100644 index 0000000..fc196a2 --- /dev/null +++ b/backend/services/healthcare/app/models/appointments.py @@ -0,0 +1,164 @@ +"""Appointment scheduling aggregates — Phase 13.1.""" +from __future__ import annotations + +import uuid +from datetime import datetime, time + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, Time, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import AppointmentStatus, DayOfWeek, GUID, LifecycleStatus + + +class Schedule( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "schedules" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + day_of_week: Mapped[DayOfWeek] = mapped_column(nullable=False) + start_time: Mapped[time] = mapped_column(Time, nullable=False) + end_time: Mapped[time] = mapped_column(Time, nullable=False) + slot_duration_minutes: Mapped[int] = mapped_column(Integer, default=30, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_schedules_tenant_code"), + Index("ix_schedules_doctor", "tenant_id", "doctor_id"), + Index("ix_schedules_clinic", "tenant_id", "clinic_id"), + ) + + +class AvailabilityWindow( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "availability_windows" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + is_bookable: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_availability_windows_tenant_code"), + Index("ix_availability_windows_doctor", "tenant_id", "doctor_id"), + Index("ix_availability_windows_starts", "tenant_id", "starts_at"), + ) + + +class AppointmentType( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "appointment_types" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + default_duration_minutes: Mapped[int] = mapped_column(Integer, default=30, nullable=False) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_appointment_types_tenant_code"), + Index("ix_appointment_types_clinic", "tenant_id", "clinic_id"), + ) + + +class Appointment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "appointments" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + appointment_type_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[AppointmentStatus] = mapped_column( + default=AppointmentStatus.DRAFT, nullable=False + ) + scheduled_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + scheduled_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + status_changed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + reminder_scheduled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_appointments_tenant_code"), + Index("ix_appointments_tenant_status", "tenant_id", "status"), + Index("ix_appointments_doctor", "tenant_id", "doctor_id"), + Index("ix_appointments_patient", "tenant_id", "patient_id"), + Index("ix_appointments_scheduled_start", "tenant_id", "scheduled_start"), + ) + + +class WaitingListEntry( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "waiting_list_entries" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + appointment_type_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + preferred_date: Mapped[str | None] = mapped_column(String(32), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + priority: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_waiting_list_entries_tenant_code"), + Index("ix_waiting_list_entries_clinic", "tenant_id", "clinic_id"), + Index("ix_waiting_list_entries_patient", "tenant_id", "patient_id"), + ) diff --git a/backend/services/healthcare/app/models/base.py b/backend/services/healthcare/app/models/base.py new file mode 100644 index 0000000..873bc66 --- /dev/null +++ b/backend/services/healthcare/app/models/base.py @@ -0,0 +1,51 @@ +"""Shared model mixins.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.types import GUID + + +class UUIDPrimaryKeyMixin: + id: Mapped[uuid.UUID] = mapped_column( + GUID(), primary_key=True, default=uuid.uuid4 + ) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class TenantMixin: + """Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded.""" + + tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + + +class ActorAuditMixin: + created_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + + +class OptimisticLockMixin: + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) diff --git a/backend/services/healthcare/app/models/clinic_management.py b/backend/services/healthcare/app/models/clinic_management.py new file mode 100644 index 0000000..540e951 --- /dev/null +++ b/backend/services/healthcare/app/models/clinic_management.py @@ -0,0 +1,126 @@ +"""Clinic management aggregates — Phase 13.3.""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ClinicPolicyKind, GUID, LifecycleStatus + + +class ClinicService( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "clinic_services" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + duration_minutes: Mapped[int] = mapped_column(Integer, default=30, nullable=False) + pricing_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_clinic_services_tenant_code"), + Index("ix_clinic_services_clinic", "tenant_id", "clinic_id"), + ) + + +class StaffAssignment( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "staff_assignments" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + role_code: Mapped[str] = mapped_column(String(50), nullable=False) + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_staff_assignments_tenant_code"), + Index("ix_staff_assignments_clinic", "tenant_id", "clinic_id"), + Index("ix_staff_assignments_doctor", "tenant_id", "doctor_id"), + ) + + +class ClinicPolicy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "clinic_policies" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[ClinicPolicyKind] = mapped_column(nullable=False) + booking_lead_time_hours: Mapped[int] = mapped_column(Integer, default=24, nullable=False) + cancellation_window_hours: Mapped[int] = mapped_column(Integer, default=12, nullable=False) + privacy_flags: Mapped[dict | None] = mapped_column(JSON, nullable=True) + rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_clinic_policies_tenant_code"), + Index("ix_clinic_policies_clinic", "tenant_id", "clinic_id"), + ) + + +class OperatingHoursPolicy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "operating_hours_policies" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + weekly_hours: Mapped[dict] = mapped_column(JSON, nullable=False) + exceptions: Mapped[dict | None] = mapped_column(JSON, nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_operating_hours_policies_tenant_code"), + Index("ix_operating_hours_clinic", "tenant_id", "clinic_id"), + ) diff --git a/backend/services/healthcare/app/models/delivery_integration.py b/backend/services/healthcare/app/models/delivery_integration.py new file mode 100644 index 0000000..ba305e1 --- /dev/null +++ b/backend/services/healthcare/app/models/delivery_integration.py @@ -0,0 +1,100 @@ +"""Delivery integration aggregates — Phase 13.7.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import DeliveryJobStatus, GUID, LifecycleStatus + + +class DeliveryIntegrationRegistration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "delivery_integration_registrations" + + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + pharmacy_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + delivery_merchant_ref: Mapped[str] = mapped_column(String(255), nullable=False) + webhook_secret_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + config: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_delivery_integration_registrations_tenant_code" + ), + Index("ix_delivery_integration_registrations_tenant", "tenant_id", "status"), + ) + + +class DeliveryJobIntent( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "delivery_job_intents" + + registration_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + prescription_order_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + delivery_job_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[DeliveryJobStatus] = mapped_column( + default=DeliveryJobStatus.QUEUED, nullable=False + ) + idempotency_key: Mapped[str] = mapped_column(String(100), nullable=False) + pickup_address: Mapped[dict | None] = mapped_column(JSON, nullable=True) + dropoff_address: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_delivery_job_intents_tenant_code"), + UniqueConstraint( + "tenant_id", "idempotency_key", name="uq_delivery_job_intents_idempotency" + ), + Index("ix_delivery_job_intents_order", "tenant_id", "prescription_order_id"), + ) + + +class DeliveryJobStatusSnapshot(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + __tablename__ = "delivery_job_status_snapshots" + + delivery_job_intent_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[DeliveryJobStatus] = mapped_column(nullable=False) + external_status: Mapped[str | None] = mapped_column(String(100), nullable=True) + recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + payload: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index( + "ix_delivery_job_status_snapshots_intent", + "tenant_id", + "delivery_job_intent_id", + ), + ) diff --git a/backend/services/healthcare/app/models/doctor_panel.py b/backend/services/healthcare/app/models/doctor_panel.py new file mode 100644 index 0000000..cf77b2f --- /dev/null +++ b/backend/services/healthcare/app/models/doctor_panel.py @@ -0,0 +1,76 @@ +"""Doctor panel aggregates — Phase 13.2.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, VisitSessionStatus + + +class VisitSession( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "visit_sessions" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + appointment_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[VisitSessionStatus] = mapped_column( + default=VisitSessionStatus.QUEUED, nullable=False + ) + queue_position: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_sessions_tenant_code"), + Index("ix_visit_sessions_doctor", "tenant_id", "doctor_id"), + Index("ix_visit_sessions_appointment", "tenant_id", "appointment_id"), + ) + + +class VisitNote( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "visit_notes" + + visit_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_notes_tenant_code"), + Index("ix_visit_notes_session", "tenant_id", "visit_session_id"), + ) diff --git a/backend/services/healthcare/app/models/foundation.py b/backend/services/healthcare/app/models/foundation.py new file mode 100644 index 0000000..3ab4d09 --- /dev/null +++ b/backend/services/healthcare/app/models/foundation.py @@ -0,0 +1,296 @@ +"""Healthcare foundation aggregates — Phase 13.0. + +Independent aggregates use UUID references within healthcare_db only. +No SQLAlchemy relationship graphs between aggregates. +No appointment/clinical workflow engines — shells and contracts only. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import AuditAction, GUID, LifecycleStatus, ProviderKind, ProviderStatus + + +class Clinic( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Root healthcare clinic shell for a tenant.""" + + __tablename__ = "clinics" + + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.DRAFT, nullable=False + ) + legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_clinics_tenant_code"), + Index("ix_clinics_tenant_status", "tenant_id", "status"), + Index("ix_clinics_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Branch( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Operational branch under a clinic.""" + + __tablename__ = "branches" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + address: Mapped[dict | None] = mapped_column(JSON, nullable=True) + timezone: Mapped[str | None] = mapped_column(String(64), nullable=True) + working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "clinic_id", "code", name="uq_branches_tenant_code" + ), + Index("ix_branches_clinic", "tenant_id", "clinic_id"), + Index("ix_branches_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Department( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Clinical department shell under a branch/clinic.""" + + __tablename__ = "departments" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + specialty: Mapped[str | None] = mapped_column(String(100), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "clinic_id", "code", name="uq_departments_tenant_code" + ), + Index("ix_departments_clinic", "tenant_id", "clinic_id"), + Index("ix_departments_branch", "tenant_id", "branch_id"), + Index("ix_departments_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcareRole( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Local healthcare RBAC role catalog shell.""" + + __tablename__ = "healthcare_roles" + + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_healthcare_roles_tenant_code"), + Index("ix_healthcare_roles_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcarePermission( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Local healthcare permission catalog shell (complements platform healthcare.* tree).""" + + __tablename__ = "healthcare_permissions" + + role_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_healthcare_permissions_tenant_code"), + Index("ix_healthcare_permissions_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExternalProviderConfig( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """External provider registration shell — credentials stay here.""" + + __tablename__ = "external_provider_configs" + + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + provider_kind: Mapped[ProviderKind] = mapped_column( + default=ProviderKind.CUSTOM, nullable=False + ) + adapter_key: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[ProviderStatus] = mapped_column( + default=ProviderStatus.REGISTERED, nullable=False + ) + priority: Mapped[int] = mapped_column(default=100, nullable=False) + credentials_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + config: Mapped[dict | None] = mapped_column(JSON, nullable=True) + capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_external_provider_configs_tenant_code" + ), + Index("ix_external_provider_configs_kind", "tenant_id", "provider_kind"), + Index("ix_external_provider_configs_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcareConfiguration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Tenant healthcare configuration — hours, policies, locale shells.""" + + __tablename__ = "healthcare_configurations" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + clinical_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + privacy_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "clinic_id", + "code", + name="uq_healthcare_configurations_tenant_code", + ), + Index("ix_healthcare_configurations_clinic", "tenant_id", "clinic_id"), + Index("ix_healthcare_configurations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcareSetting( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Key/value healthcare settings shell.""" + + __tablename__ = "healthcare_settings" + + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + key: Mapped[str] = mapped_column(String(100), nullable=False) + value: Mapped[dict | None] = mapped_column(JSON, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "clinic_id", + "branch_id", + "key", + name="uq_healthcare_settings_tenant_key", + ), + Index("ix_healthcare_settings_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class HealthcareAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + """Append-only healthcare audit trail.""" + + __tablename__ = "healthcare_audit_logs" + + entity_type: Mapped[str] = mapped_column(String(50), nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + action: Mapped[AuditAction] = mapped_column(nullable=False) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + changes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index( + "ix_healthcare_audit_entity", + "tenant_id", + "entity_type", + "entity_id", + ), + ) diff --git a/backend/services/healthcare/app/models/medical_records.py b/backend/services/healthcare/app/models/medical_records.py new file mode 100644 index 0000000..f39ad27 --- /dev/null +++ b/backend/services/healthcare/app/models/medical_records.py @@ -0,0 +1,178 @@ +"""Medical record aggregates — Phase 13.5.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import EncounterStatus, GUID + + +class MedicalRecord( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "medical_records" + + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + access_policy: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_medical_records_tenant_code"), + Index("ix_medical_records_patient", "tenant_id", "patient_id"), + ) + + +class Encounter( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "encounters" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + visit_session_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + appointment_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[EncounterStatus] = mapped_column( + default=EncounterStatus.DRAFT, nullable=False + ) + chief_complaint: Mapped[str | None] = mapped_column(Text, nullable=True) + clinical_summary: Mapped[str | None] = mapped_column(Text, nullable=True) + finalized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + addendum: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_encounters_tenant_code"), + Index("ix_encounters_record", "tenant_id", "medical_record_id"), + ) + + +class Condition( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "conditions" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + display: Mapped[str] = mapped_column(String(255), nullable=False) + clinical_status: Mapped[str | None] = mapped_column(String(50), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_conditions_tenant_code"), + Index("ix_conditions_record", "tenant_id", "medical_record_id"), + ) + + +class Allergy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "allergies" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + substance: Mapped[str] = mapped_column(String(255), nullable=False) + severity: Mapped[str | None] = mapped_column(String(50), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_allergies_tenant_code"), + Index("ix_allergies_record", "tenant_id", "medical_record_id"), + ) + + +class MedicationStatement( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "medication_statements" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + medication_code: Mapped[str] = mapped_column(String(100), nullable=False) + display: Mapped[str] = mapped_column(String(255), nullable=False) + dosage: Mapped[str | None] = mapped_column(String(100), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_medication_statements_tenant_code"), + Index("ix_medication_statements_record", "tenant_id", "medical_record_id"), + ) + + +class ImmunizationRecord( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "immunization_records" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + vaccine_code: Mapped[str] = mapped_column(String(100), nullable=False) + administered_on: Mapped[str | None] = mapped_column(String(32), nullable=True) + storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_immunization_records_tenant_code"), + Index("ix_immunization_records_record", "tenant_id", "medical_record_id"), + ) + + +class MedicalRecordAccessLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + __tablename__ = "medical_record_access_logs" + + medical_record_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + actor_user_id: Mapped[str] = mapped_column(String(100), nullable=False) + action: Mapped[str] = mapped_column(String(50), nullable=False) + reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_medical_record_access_logs_record", "tenant_id", "medical_record_id"), + ) diff --git a/backend/services/healthcare/app/models/outbox.py b/backend/services/healthcare/app/models/outbox.py new file mode 100644 index 0000000..136e233 --- /dev/null +++ b/backend/services/healthcare/app/models/outbox.py @@ -0,0 +1,44 @@ +"""Transactional outbox model — ADR-006.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, func +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import UUIDPrimaryKeyMixin +from app.models.types import GUID +from shared.events import EventStatus + + +class OutboxEvent(UUIDPrimaryKeyMixin, Base): + """Pending domain events written in the same DB transaction as mutations.""" + + __tablename__ = "outbox_events" + + tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + event_type: Mapped[str] = mapped_column(String(150), nullable=False) + aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False) + aggregate_id: Mapped[str] = mapped_column(String(100), nullable=False) + payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + status: Mapped[EventStatus] = mapped_column( + SAEnum(EventStatus, name="healthcare_event_status", native_enum=False), + default=EventStatus.PENDING, + nullable=False, + ) + retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + processed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + Index("ix_healthcare_outbox_status", "status"), + Index("ix_healthcare_outbox_tenant", "tenant_id"), + ) diff --git a/backend/services/healthcare/app/models/patient_portal.py b/backend/services/healthcare/app/models/patient_portal.py new file mode 100644 index 0000000..25adcb2 --- /dev/null +++ b/backend/services/healthcare/app/models/patient_portal.py @@ -0,0 +1,94 @@ +"""Patient portal aggregates — Phase 13.4.""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID + + +class PatientPortalProfile( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "patient_portal_profiles" + + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + preferred_clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + preferred_language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + emergency_contact_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + notification_prefs: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "patient_id", name="uq_patient_portal_profiles_patient"), + Index("ix_patient_portal_profiles_tenant", "tenant_id", "patient_id"), + ) + + +class PatientDocumentRef( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "patient_document_refs" + + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + storage_file_ref: Mapped[str] = mapped_column(String(255), nullable=False) + document_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_patient_document_refs_tenant_code"), + Index("ix_patient_document_refs_patient", "tenant_id", "patient_id"), + ) + + +class PatientNotificationPreference( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "patient_notification_preferences" + + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + channel: Mapped[str] = mapped_column(String(32), nullable=False) + template_key: Mapped[str] = mapped_column(String(100), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "patient_id", + "channel", + "template_key", + name="uq_patient_notification_prefs", + ), + Index("ix_patient_notification_prefs_patient", "tenant_id", "patient_id"), + ) diff --git a/backend/services/healthcare/app/models/pharmacy.py b/backend/services/healthcare/app/models/pharmacy.py new file mode 100644 index 0000000..02c30fb --- /dev/null +++ b/backend/services/healthcare/app/models/pharmacy.py @@ -0,0 +1,124 @@ +"""Pharmacy network aggregates — Phase 13.6.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, LifecycleStatus, PrescriptionOrderStatus + + +class Pharmacy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "pharmacies" + + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + contact: Mapped[dict | None] = mapped_column(JSON, nullable=True) + hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + service_area_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_pharmacies_tenant_code"), + Index("ix_pharmacies_tenant", "tenant_id", "status"), + ) + + +class PrescriptionOrder( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "prescription_orders" + + pharmacy_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + encounter_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + visit_session_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[PrescriptionOrderStatus] = mapped_column( + default=PrescriptionOrderStatus.SUBMITTED, nullable=False + ) + status_changed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_prescription_orders_tenant_code"), + Index("ix_prescription_orders_pharmacy", "tenant_id", "pharmacy_id"), + Index("ix_prescription_orders_status", "tenant_id", "status"), + ) + + +class PrescriptionLine( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + __tablename__ = "prescription_lines" + + prescription_order_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + line_no: Mapped[int] = mapped_column(Integer, nullable=False) + medication_code: Mapped[str] = mapped_column(String(100), nullable=False) + display: Mapped[str] = mapped_column(String(255), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + dosage_instructions: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "prescription_order_id", + "line_no", + name="uq_prescription_lines_order_line", + ), + Index("ix_prescription_lines_order", "tenant_id", "prescription_order_id"), + ) + + +class PrescriptionStatusHistory(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + __tablename__ = "prescription_status_history" + + prescription_order_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + from_status: Mapped[PrescriptionOrderStatus] = mapped_column(nullable=False) + to_status: Mapped[PrescriptionOrderStatus] = mapped_column(nullable=False) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_prescription_status_history_order", "tenant_id", "prescription_order_id"), + ) diff --git a/backend/services/healthcare/app/models/profiles.py b/backend/services/healthcare/app/models/profiles.py new file mode 100644 index 0000000..bfd4762 --- /dev/null +++ b/backend/services/healthcare/app/models/profiles.py @@ -0,0 +1,85 @@ +"""Doctor and Patient profile shells — Phase 13.0.""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import GUID, LifecycleStatus, ProfileStatus + + +class Doctor( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Doctor profile shell — links to Identity user ref only.""" + + __tablename__ = "doctors" + + user_id: Mapped[str] = mapped_column(String(100), nullable=False) + clinic_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + department_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + specialty: Mapped[str | None] = mapped_column(String(100), nullable=True) + license_number: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[ProfileStatus] = mapped_column( + default=ProfileStatus.ACTIVE, nullable=False + ) + contact: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_doctors_tenant_code"), + UniqueConstraint("tenant_id", "user_id", name="uq_doctors_tenant_user"), + Index("ix_doctors_clinic", "tenant_id", "clinic_id"), + Index("ix_doctors_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Patient( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Patient profile shell — links to Identity user ref only.""" + + __tablename__ = "patients" + + user_id: Mapped[str] = mapped_column(String(100), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[ProfileStatus] = mapped_column( + default=ProfileStatus.ACTIVE, nullable=False + ) + date_of_birth: Mapped[str | None] = mapped_column(String(32), nullable=True) + gender: Mapped[str | None] = mapped_column(String(32), nullable=True) + contact: Mapped[dict | None] = mapped_column(JSON, nullable=True) + emergency_contact: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_patients_tenant_code"), + UniqueConstraint("tenant_id", "user_id", name="uq_patients_tenant_user"), + Index("ix_patients_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/healthcare/app/models/types.py b/backend/services/healthcare/app/models/types.py new file mode 100644 index 0000000..8a9a50e --- /dev/null +++ b/backend/services/healthcare/app/models/types.py @@ -0,0 +1,175 @@ +"""Healthcare domain enums and dialect-safe GUID type.""" +from __future__ import annotations + +import enum +import uuid + +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.types import CHAR, TypeDecorator + + +class GUID(TypeDecorator): + impl = CHAR + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(PG_UUID(as_uuid=True)) + return dialect.type_descriptor(CHAR(36)) + + def process_bind_param(self, value, dialect): + if value is None: + return value + if dialect.name == "postgresql": + return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value)) + return str(value) + + def process_result_value(self, value, dialect): + if value is None: + return value + if isinstance(value, uuid.UUID): + return value + return uuid.UUID(str(value)) + + +class LifecycleStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class ProfileStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class ProviderKind(str, enum.Enum): + COMMUNICATION = "communication" + CRM = "crm" + LOYALTY = "loyalty" + ACCOUNTING = "accounting" + DELIVERY = "delivery" + STORAGE = "storage" + AI = "ai" + IDENTITY = "identity" + CUSTOM = "custom" + + +class ProviderStatus(str, enum.Enum): + REGISTERED = "registered" + ACTIVE = "active" + INACTIVE = "inactive" + FAILED = "failed" + RETIRED = "retired" + + +class AuditAction(str, enum.Enum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + RESTORE = "restore" + STATUS_CHANGE = "status_change" + REGISTER = "register" + ENABLE = "enable" + DISABLE = "disable" + + +# Phase 13.1 — Appointment Engine +class DayOfWeek(str, enum.Enum): + MONDAY = "monday" + TUESDAY = "tuesday" + WEDNESDAY = "wednesday" + THURSDAY = "thursday" + FRIDAY = "friday" + SATURDAY = "saturday" + SUNDAY = "sunday" + + +class AppointmentStatus(str, enum.Enum): + DRAFT = "draft" + CONFIRMED = "confirmed" + CHECKED_IN = "checked_in" + COMPLETED = "completed" + CANCELLED = "cancelled" + NO_SHOW = "no_show" + + +class AppointmentLifecycleAction(str, enum.Enum): + BOOK = "book" + CONFIRM = "confirm" + CHECK_IN = "check_in" + COMPLETE = "complete" + CANCEL = "cancel" + RESCHEDULE = "reschedule" + MARK_NO_SHOW = "mark_no_show" + + +# Phase 13.2 — Doctor Panel +class VisitSessionStatus(str, enum.Enum): + QUEUED = "queued" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + DEFERRED = "deferred" + CANCELLED = "cancelled" + + +class VisitSessionAction(str, enum.Enum): + CALL_NEXT = "call_next" + START = "start" + FINISH = "finish" + DEFER = "defer" + + +# Phase 13.3 — Clinic Management +class ClinicPolicyKind(str, enum.Enum): + BOOKING = "booking" + CANCELLATION = "cancellation" + PRIVACY = "privacy" + + +# Phase 13.5 — Medical Record +class EncounterStatus(str, enum.Enum): + DRAFT = "draft" + IN_PROGRESS = "in_progress" + FINALIZED = "finalized" + + +class ClinicalListKind(str, enum.Enum): + CONDITION = "condition" + ALLERGY = "allergy" + MEDICATION = "medication" + IMMUNIZATION = "immunization" + + +# Phase 13.6 — Pharmacy Network +class PrescriptionOrderStatus(str, enum.Enum): + SUBMITTED = "submitted" + ACCEPTED = "accepted" + PREPARING = "preparing" + READY = "ready" + PICKED_UP = "picked_up" + CANCELLED = "cancelled" + + +class PrescriptionLifecycleAction(str, enum.Enum): + SUBMIT = "submit" + ACCEPT = "accept" + PREPARE = "prepare" + MARK_READY = "mark_ready" + COMPLETE_PICKUP = "complete_pickup" + CANCEL = "cancel" + + +# Phase 13.7 — Delivery Integration +class DeliveryJobStatus(str, enum.Enum): + QUEUED = "queued" + PICKED_UP = "picked_up" + IN_TRANSIT = "in_transit" + DELIVERED = "delivered" + FAILED = "failed" + CANCELLED = "cancelled" diff --git a/backend/services/healthcare/app/permissions/definitions.py b/backend/services/healthcare/app/permissions/definitions.py new file mode 100644 index 0000000..c7286f6 --- /dev/null +++ b/backend/services/healthcare/app/permissions/definitions.py @@ -0,0 +1,183 @@ +"""Healthcare permission definitions — Phase 13.0 foundation.""" +from __future__ import annotations + +HEALTHCARE_VIEW = "healthcare.view" +HEALTHCARE_MANAGE = "healthcare.manage" + +CLINICS_VIEW = "healthcare.clinics.view" +CLINICS_CREATE = "healthcare.clinics.create" +CLINICS_UPDATE = "healthcare.clinics.update" +CLINICS_DELETE = "healthcare.clinics.delete" +CLINICS_MANAGE = "healthcare.clinics.manage" + +BRANCHES_VIEW = "healthcare.branches.view" +BRANCHES_CREATE = "healthcare.branches.create" +BRANCHES_UPDATE = "healthcare.branches.update" +BRANCHES_DELETE = "healthcare.branches.delete" +BRANCHES_MANAGE = "healthcare.branches.manage" + +DEPARTMENTS_VIEW = "healthcare.departments.view" +DEPARTMENTS_CREATE = "healthcare.departments.create" +DEPARTMENTS_UPDATE = "healthcare.departments.update" +DEPARTMENTS_DELETE = "healthcare.departments.delete" +DEPARTMENTS_MANAGE = "healthcare.departments.manage" + +DOCTORS_VIEW = "healthcare.doctors.view" +DOCTORS_CREATE = "healthcare.doctors.create" +DOCTORS_UPDATE = "healthcare.doctors.update" +DOCTORS_DELETE = "healthcare.doctors.delete" +DOCTORS_MANAGE = "healthcare.doctors.manage" + +PATIENTS_VIEW = "healthcare.patients.view" +PATIENTS_CREATE = "healthcare.patients.create" +PATIENTS_UPDATE = "healthcare.patients.update" +PATIENTS_DELETE = "healthcare.patients.delete" +PATIENTS_MANAGE = "healthcare.patients.manage" + +EXTERNAL_PROVIDERS_VIEW = "healthcare.external_providers.view" +EXTERNAL_PROVIDERS_CREATE = "healthcare.external_providers.create" +EXTERNAL_PROVIDERS_UPDATE = "healthcare.external_providers.update" +EXTERNAL_PROVIDERS_DELETE = "healthcare.external_providers.delete" +EXTERNAL_PROVIDERS_MANAGE = "healthcare.external_providers.manage" + +CONFIGURATIONS_VIEW = "healthcare.configurations.view" +CONFIGURATIONS_CREATE = "healthcare.configurations.create" +CONFIGURATIONS_UPDATE = "healthcare.configurations.update" +CONFIGURATIONS_DELETE = "healthcare.configurations.delete" +CONFIGURATIONS_MANAGE = "healthcare.configurations.manage" + +SETTINGS_VIEW = "healthcare.settings.view" +SETTINGS_MANAGE = "healthcare.settings.manage" + +AUDIT_VIEW = "healthcare.audit.view" + +# Planned trees (registered for discovery; enforced in later phases) +APPOINTMENTS_VIEW = "healthcare.appointments.view" +APPOINTMENTS_MANAGE = "healthcare.appointments.manage" +SCHEDULES_VIEW = "healthcare.schedules.view" +SCHEDULES_MANAGE = "healthcare.schedules.manage" +DOCTOR_PANEL_VIEW = "healthcare.doctor_panel.view" +VISIT_SESSIONS_MANAGE = "healthcare.visit_sessions.manage" +VISIT_NOTES_MANAGE = "healthcare.visit_notes.manage" +CLINIC_ADMIN_VIEW = "healthcare.clinic_admin.view" +CLINIC_ADMIN_MANAGE = "healthcare.clinic_admin.manage" +CLINIC_SERVICES_VIEW = "healthcare.clinic_services.view" +CLINIC_SERVICES_MANAGE = "healthcare.clinic_services.manage" +PATIENT_PORTAL_VIEW = "healthcare.patient_portal.view" +PATIENT_PORTAL_MANAGE = "healthcare.patient_portal.manage" +MEDICAL_RECORDS_VIEW = "healthcare.medical_records.view" +MEDICAL_RECORDS_MANAGE = "healthcare.medical_records.manage" +ENCOUNTERS_MANAGE = "healthcare.encounters.manage" +PRESCRIPTIONS_SUBMIT = "healthcare.prescriptions.submit" +PHARMACIES_MANAGE = "healthcare.pharmacies.manage" +PRESCRIPTION_ORDERS_FULFILL = "healthcare.prescription_orders.fulfill" +DELIVERY_INTEGRATION_VIEW = "healthcare.delivery_integration.view" +DELIVERY_INTEGRATION_MANAGE = "healthcare.delivery_integration.manage" +DELIVERY_INTEGRATION_SUBMIT = "healthcare.delivery_integration.submit" +VISITS_VIEW = "healthcare.visits.view" +VISITS_MANAGE = "healthcare.visits.manage" +PHARMACY_VIEW = "healthcare.pharmacy.view" +PHARMACY_MANAGE = "healthcare.pharmacy.manage" +RECORDS_VIEW = "healthcare.records.view" +RECORDS_MANAGE = "healthcare.records.manage" + +ALL_PERMISSIONS: list[str] = [ + HEALTHCARE_VIEW, + HEALTHCARE_MANAGE, + CLINICS_VIEW, + CLINICS_CREATE, + CLINICS_UPDATE, + CLINICS_DELETE, + CLINICS_MANAGE, + BRANCHES_VIEW, + BRANCHES_CREATE, + BRANCHES_UPDATE, + BRANCHES_DELETE, + BRANCHES_MANAGE, + DEPARTMENTS_VIEW, + DEPARTMENTS_CREATE, + DEPARTMENTS_UPDATE, + DEPARTMENTS_DELETE, + DEPARTMENTS_MANAGE, + DOCTORS_VIEW, + DOCTORS_CREATE, + DOCTORS_UPDATE, + DOCTORS_DELETE, + DOCTORS_MANAGE, + PATIENTS_VIEW, + PATIENTS_CREATE, + PATIENTS_UPDATE, + PATIENTS_DELETE, + PATIENTS_MANAGE, + EXTERNAL_PROVIDERS_VIEW, + EXTERNAL_PROVIDERS_CREATE, + EXTERNAL_PROVIDERS_UPDATE, + EXTERNAL_PROVIDERS_DELETE, + EXTERNAL_PROVIDERS_MANAGE, + CONFIGURATIONS_VIEW, + CONFIGURATIONS_CREATE, + CONFIGURATIONS_UPDATE, + CONFIGURATIONS_DELETE, + CONFIGURATIONS_MANAGE, + SETTINGS_VIEW, + SETTINGS_MANAGE, + AUDIT_VIEW, + APPOINTMENTS_VIEW, + APPOINTMENTS_MANAGE, + SCHEDULES_VIEW, + SCHEDULES_MANAGE, + DOCTOR_PANEL_VIEW, + VISIT_SESSIONS_MANAGE, + VISIT_NOTES_MANAGE, + CLINIC_ADMIN_VIEW, + CLINIC_ADMIN_MANAGE, + CLINIC_SERVICES_VIEW, + CLINIC_SERVICES_MANAGE, + PATIENT_PORTAL_VIEW, + PATIENT_PORTAL_MANAGE, + MEDICAL_RECORDS_VIEW, + MEDICAL_RECORDS_MANAGE, + ENCOUNTERS_MANAGE, + PRESCRIPTIONS_SUBMIT, + PHARMACIES_MANAGE, + PRESCRIPTION_ORDERS_FULFILL, + DELIVERY_INTEGRATION_VIEW, + DELIVERY_INTEGRATION_MANAGE, + DELIVERY_INTEGRATION_SUBMIT, + VISITS_VIEW, + VISITS_MANAGE, + PHARMACY_VIEW, + PHARMACY_MANAGE, + RECORDS_VIEW, + RECORDS_MANAGE, +] + +PERMISSION_PREFIXES: tuple[str, ...] = ( + "healthcare.", + "healthcare.clinics.", + "healthcare.branches.", + "healthcare.departments.", + "healthcare.doctors.", + "healthcare.patients.", + "healthcare.external_providers.", + "healthcare.configurations.", + "healthcare.settings.", + "healthcare.audit.", + "healthcare.appointments.", + "healthcare.schedules.", + "healthcare.doctor_panel.", + "healthcare.visit_sessions.", + "healthcare.visit_notes.", + "healthcare.clinic_admin.", + "healthcare.clinic_services.", + "healthcare.patient_portal.", + "healthcare.medical_records.", + "healthcare.encounters.", + "healthcare.prescriptions.", + "healthcare.pharmacies.", + "healthcare.prescription_orders.", + "healthcare.delivery_integration.", + "healthcare.visits.", + "healthcare.pharmacy.", + "healthcare.records.", +) diff --git a/backend/services/healthcare/app/policies/__init__.py b/backend/services/healthcare/app/policies/__init__.py new file mode 100644 index 0000000..5ee39bc --- /dev/null +++ b/backend/services/healthcare/app/policies/__init__.py @@ -0,0 +1,4 @@ +"""Healthcare domain policies package.""" +from app.policies.appointments import AppointmentLifecyclePolicy + +__all__ = ["AppointmentLifecyclePolicy"] diff --git a/backend/services/healthcare/app/policies/appointments.py b/backend/services/healthcare/app/policies/appointments.py new file mode 100644 index 0000000..7addbbd --- /dev/null +++ b/backend/services/healthcare/app/policies/appointments.py @@ -0,0 +1,13 @@ +"""Appointment lifecycle policies — Phase 13.1.""" +from __future__ import annotations + +from app.models.types import AppointmentLifecycleAction, AppointmentStatus +from app.validators.appointments import ensure_appointment_lifecycle_transition, target_status_for + + +class AppointmentLifecyclePolicy: + def ensure_transition( + self, *, action: AppointmentLifecycleAction, current: AppointmentStatus + ) -> AppointmentStatus: + ensure_appointment_lifecycle_transition(action=action, current=current) + return target_status_for(action) diff --git a/backend/services/healthcare/app/policies/doctor_panel.py b/backend/services/healthcare/app/policies/doctor_panel.py new file mode 100644 index 0000000..057e9a6 --- /dev/null +++ b/backend/services/healthcare/app/policies/doctor_panel.py @@ -0,0 +1,13 @@ +"""Visit session lifecycle policies — Phase 13.2.""" +from __future__ import annotations + +from app.models.types import VisitSessionAction, VisitSessionStatus +from app.validators.doctor_panel import ensure_visit_session_transition, target_status_for + + +class VisitSessionPolicy: + def ensure_transition( + self, *, action: VisitSessionAction, current: VisitSessionStatus + ) -> VisitSessionStatus: + ensure_visit_session_transition(action=action, current=current) + return target_status_for(action) diff --git a/backend/services/healthcare/app/policies/medical_records.py b/backend/services/healthcare/app/policies/medical_records.py new file mode 100644 index 0000000..13d33bf --- /dev/null +++ b/backend/services/healthcare/app/policies/medical_records.py @@ -0,0 +1,25 @@ +"""Medical record access policies — Phase 13.5.""" +from __future__ import annotations + +from uuid import UUID + +from shared.exceptions import ForbiddenError + + +class MedicalRecordAccessPolicy: + def ensure_read_access( + self, + *, + access_policy: dict | None, + actor_user_id: str, + doctor_id: UUID | None = None, + ) -> None: + if not access_policy: + return + allowed_doctors = access_policy.get("allowed_doctor_ids") or [] + if allowed_doctors and doctor_id and str(doctor_id) not in allowed_doctors: + if actor_user_id not in (access_policy.get("break_glass_users") or []): + raise ForbiddenError( + "دسترسی به پرونده پزشکی مجاز نیست", + error_code="medical_record_access_denied", + ) diff --git a/backend/services/healthcare/app/policies/pharmacy.py b/backend/services/healthcare/app/policies/pharmacy.py new file mode 100644 index 0000000..d04975b --- /dev/null +++ b/backend/services/healthcare/app/policies/pharmacy.py @@ -0,0 +1,13 @@ +"""Prescription lifecycle policies — Phase 13.6.""" +from __future__ import annotations + +from app.models.types import PrescriptionLifecycleAction, PrescriptionOrderStatus +from app.validators.pharmacy import ensure_prescription_transition, target_prescription_status + + +class PrescriptionLifecyclePolicy: + def ensure_transition( + self, *, action: PrescriptionLifecycleAction, current: PrescriptionOrderStatus + ) -> PrescriptionOrderStatus: + ensure_prescription_transition(action=action, current=current) + return target_prescription_status(action) diff --git a/backend/services/healthcare/app/providers/__init__.py b/backend/services/healthcare/app/providers/__init__.py new file mode 100644 index 0000000..abfcbbb --- /dev/null +++ b/backend/services/healthcare/app/providers/__init__.py @@ -0,0 +1,11 @@ +"""Platform provider contracts package.""" +from app.providers.contracts import ( # noqa: F401 + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + DeliveryProvider, + IdentityProvider, + LoyaltyProvider, + StorageProvider, +) diff --git a/backend/services/healthcare/app/providers/contracts.py b/backend/services/healthcare/app/providers/contracts.py new file mode 100644 index 0000000..e22f2f0 --- /dev/null +++ b/backend/services/healthcare/app/providers/contracts.py @@ -0,0 +1,75 @@ +"""Platform capability contracts — interfaces only; no implementations in Healthcare.""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID + + +class AccountingProvider(Protocol): + """Contract for Accounting. Healthcare must not post journals.""" + + async def create_billing_intent( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: ... + + +class CRMProvider(Protocol): + """Contract for CRM. Healthcare stores external contact refs only.""" + + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: ... + + +class LoyaltyProvider(Protocol): + """Contract for Loyalty. Healthcare must not own points/ledger.""" + + async def resolve_member( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: ... + + +class CommunicationProvider(Protocol): + """Contract for Communication Platform. Healthcare must not own SMS providers.""" + + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> None: ... + + +class DeliveryProvider(Protocol): + """Contract for Delivery. Healthcare stores delivery job refs only.""" + + async def request_delivery( + self, *, tenant_id: UUID, request: dict[str, Any] + ) -> dict[str, Any]: ... + + +class StorageProvider(Protocol): + """Contract for File Storage. Healthcare stores document refs only.""" + + async def resolve_file( + self, *, tenant_id: UUID, file_ref: str + ) -> dict[str, Any]: ... + + +class AIProvider(Protocol): + """Contract for AI Platform. Healthcare must not own model inference.""" + + async def suggest( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: ... + + +class IdentityProvider(Protocol): + """Contract for Identity. Healthcare stores user_id refs only.""" + + async def resolve_user( + self, *, tenant_id: UUID, user_ref: str + ) -> dict[str, Any]: ... diff --git a/backend/services/healthcare/app/providers/mocks.py b/backend/services/healthcare/app/providers/mocks.py new file mode 100644 index 0000000..1bdf56a --- /dev/null +++ b/backend/services/healthcare/app/providers/mocks.py @@ -0,0 +1,89 @@ +"""No-op mock implementations of platform provider contracts — Healthcare.""" +from __future__ import annotations + +from typing import Any +from uuid import UUID + + +class MockAccountingProvider: + async def create_billing_intent( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: + return {"ok": True, "reference": reference, "provider": "mock_accounting"} + + +class MockCRMProvider: + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: + return {"ok": True, "contact_ref": contact_ref, "provider": "mock_crm"} + + +class MockLoyaltyProvider: + async def resolve_member( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: + return {"ok": True, "member_ref": member_ref, "provider": "mock_loyalty"} + + +class MockCommunicationProvider: + sent: list[dict[str, Any]] = [] + + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> None: + MockCommunicationProvider.sent.append( + { + "tenant_id": tenant_id, + "channel": channel, + "template_key": template_key, + "to": to, + "payload": payload, + } + ) + + @classmethod + def reset(cls) -> None: + cls.sent = [] + + +class MockDeliveryProvider: + jobs: list[dict[str, Any]] = [] + + async def request_delivery( + self, *, tenant_id: UUID, request: dict[str, Any] + ) -> dict[str, Any]: + ref = f"mock-delivery-{len(self.jobs) + 1}" + self.jobs.append({"tenant_id": tenant_id, "request": request, "ref": ref}) + return {"ok": True, "delivery_ref": ref, "provider": "mock_delivery"} + + @classmethod + def reset(cls) -> None: + cls.jobs = [] + + +class MockStorageProvider: + async def resolve_file( + self, *, tenant_id: UUID, file_ref: str + ) -> dict[str, Any]: + return {"ok": True, "file_ref": file_ref, "provider": "mock_storage"} + + +class MockAIProvider: + async def suggest( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: + return {"ok": True, "task": task, "provider": "mock_ai"} + + +class MockIdentityProvider: + async def resolve_user( + self, *, tenant_id: UUID, user_ref: str + ) -> dict[str, Any]: + return {"ok": True, "user_ref": user_ref, "provider": "mock_identity"} diff --git a/backend/services/healthcare/app/queries/__init__.py b/backend/services/healthcare/app/queries/__init__.py new file mode 100644 index 0000000..195a551 --- /dev/null +++ b/backend/services/healthcare/app/queries/__init__.py @@ -0,0 +1,4 @@ +"""Profile queries package.""" +from app.queries.profiles import ProfileQueries + +__all__ = ["ProfileQueries"] diff --git a/backend/services/healthcare/app/queries/appointments.py b/backend/services/healthcare/app/queries/appointments.py new file mode 100644 index 0000000..2df4991 --- /dev/null +++ b/backend/services/healthcare/app/queries/appointments.py @@ -0,0 +1,49 @@ +"""Appointment query handlers — Phase 13.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.appointments import ( + AppointmentService, + AppointmentTypeService, + AvailabilityWindowService, + ScheduleService, + WaitingListService, +) +from app.specifications.appointments import AppointmentListSpec + + +class AppointmentQueries: + def __init__(self, session: AsyncSession) -> None: + self.schedules = ScheduleService(session) + self.availability = AvailabilityWindowService(session) + self.types = AppointmentTypeService(session) + self.appointments = AppointmentService(session) + self.waiting_list = WaitingListService(session) + + async def list_schedules(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.schedules.list(tenant_id, offset=offset, limit=limit) + + async def list_availability(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.availability.list(tenant_id, offset=offset, limit=limit) + + async def list_types(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.types.list(tenant_id, offset=offset, limit=limit) + + async def get_appointment(self, tenant_id: UUID, appointment_id: UUID): + return await self.appointments.get(tenant_id, appointment_id) + + async def list_appointments( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: AppointmentListSpec | None = None, + ): + return await self.appointments.list(tenant_id, offset=offset, limit=limit, spec=spec) + + async def list_waiting_list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.waiting_list.list(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/healthcare/app/queries/doctor_panel.py b/backend/services/healthcare/app/queries/doctor_panel.py new file mode 100644 index 0000000..76a97d4 --- /dev/null +++ b/backend/services/healthcare/app/queries/doctor_panel.py @@ -0,0 +1,37 @@ +"""Doctor panel query handlers — Phase 13.2.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.doctor_panel import VisitNoteService, VisitSessionService +from app.specifications.doctor_panel import VisitSessionListSpec + + +class DoctorPanelQueries: + def __init__(self, session: AsyncSession) -> None: + self.sessions = VisitSessionService(session) + self.notes = VisitNoteService(session) + + async def get_session(self, tenant_id: UUID, visit_session_id: UUID): + return await self.sessions.get(tenant_id, visit_session_id) + + async def list_sessions( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: VisitSessionListSpec | None = None, + ): + return await self.sessions.list(tenant_id, offset=offset, limit=limit, spec=spec) + + async def dashboard(self, tenant_id: UUID, *, doctor_id: UUID): + return await self.sessions.dashboard(tenant_id, doctor_id=doctor_id) + + async def queue(self, tenant_id: UUID, *, doctor_id: UUID): + return await self.sessions.queue(tenant_id, doctor_id=doctor_id) + + async def list_notes(self, tenant_id: UUID, visit_session_id: UUID): + return await self.notes.list_by_session(tenant_id, visit_session_id) diff --git a/backend/services/healthcare/app/queries/profiles.py b/backend/services/healthcare/app/queries/profiles.py new file mode 100644 index 0000000..4294720 --- /dev/null +++ b/backend/services/healthcare/app/queries/profiles.py @@ -0,0 +1,26 @@ +"""Profile query handlers — Phase 13.0.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.profiles import DoctorService, PatientService + + +class ProfileQueries: + def __init__(self, session: AsyncSession) -> None: + self.doctors = DoctorService(session) + self.patients = PatientService(session) + + async def get_doctor(self, tenant_id: UUID, doctor_id: UUID): + return await self.doctors.get(tenant_id, doctor_id) + + async def list_doctors(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.doctors.list(tenant_id, offset=offset, limit=limit) + + async def get_patient(self, tenant_id: UUID, patient_id: UUID): + return await self.patients.get(tenant_id, patient_id) + + async def list_patients(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return await self.patients.list(tenant_id, offset=offset, limit=limit) diff --git a/backend/services/healthcare/app/repositories/appointments.py b/backend/services/healthcare/app/repositories/appointments.py new file mode 100644 index 0000000..6386dc2 --- /dev/null +++ b/backend/services/healthcare/app/repositories/appointments.py @@ -0,0 +1,153 @@ +"""Appointment repositories — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlalchemy import and_, func, or_, select + +from app.models.appointments import ( + Appointment, + AppointmentType, + AvailabilityWindow, + Schedule, + WaitingListEntry, +) +from app.models.types import AppointmentStatus +from app.repositories.base import TenantBaseRepository +from app.specifications.appointments import AppointmentListSpec + + +class ScheduleRepository(TenantBaseRepository[Schedule]): + model = Schedule + + async def get_by_code(self, tenant_id: UUID, code: str) -> Schedule | None: + stmt = select(Schedule).where( + Schedule.tenant_id == tenant_id, + Schedule.code == code, + Schedule.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AvailabilityWindowRepository(TenantBaseRepository[AvailabilityWindow]): + model = AvailabilityWindow + + async def get_by_code(self, tenant_id: UUID, code: str) -> AvailabilityWindow | None: + stmt = select(AvailabilityWindow).where( + AvailabilityWindow.tenant_id == tenant_id, + AvailabilityWindow.code == code, + AvailabilityWindow.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AppointmentTypeRepository(TenantBaseRepository[AppointmentType]): + model = AppointmentType + + async def get_by_code(self, tenant_id: UUID, code: str) -> AppointmentType | None: + stmt = select(AppointmentType).where( + AppointmentType.tenant_id == tenant_id, + AppointmentType.code == code, + AppointmentType.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AppointmentRepository(TenantBaseRepository[Appointment]): + model = Appointment + + async def get_by_code(self, tenant_id: UUID, code: str) -> Appointment | None: + stmt = select(Appointment).where( + Appointment.tenant_id == tenant_id, + Appointment.code == code, + Appointment.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def find_conflicts( + self, + tenant_id: UUID, + *, + doctor_id: UUID, + scheduled_start: datetime, + scheduled_end: datetime, + exclude_id: UUID | None = None, + ) -> list[Appointment]: + clauses = [ + Appointment.tenant_id == tenant_id, + Appointment.doctor_id == doctor_id, + Appointment.is_deleted.is_(False), + Appointment.status.not_in( + [AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW] + ), + Appointment.scheduled_start < scheduled_end, + Appointment.scheduled_end > scheduled_start, + ] + if exclude_id is not None: + clauses.append(Appointment.id != exclude_id) + stmt = select(Appointment).where(and_(*clauses)) + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + def _filter_clauses(self, tenant_id: UUID, spec: AppointmentListSpec | None): + clauses = [ + Appointment.tenant_id == tenant_id, + Appointment.is_deleted.is_(False), + ] + if spec is None: + return clauses + if spec.status is not None: + clauses.append(Appointment.status == spec.status) + if spec.doctor_id is not None: + clauses.append(Appointment.doctor_id == spec.doctor_id) + if spec.clinic_id is not None: + clauses.append(Appointment.clinic_id == spec.clinic_id) + if spec.patient_id is not None: + clauses.append(Appointment.patient_id == spec.patient_id) + if spec.date_from is not None: + clauses.append(Appointment.scheduled_start >= spec.date_from) + if spec.date_to is not None: + clauses.append(Appointment.scheduled_start <= spec.date_to) + if spec.q: + pattern = f"%{spec.q}%" + clauses.append(or_(Appointment.code.ilike(pattern), Appointment.notes.ilike(pattern))) + return clauses + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: AppointmentListSpec | None = None, + ): + clauses = self._filter_clauses(tenant_id, spec) + sort_col = getattr(Appointment, spec.sort_by if spec else "scheduled_start", Appointment.scheduled_start) + order = sort_col.asc() if spec and spec.sort_dir == "asc" else sort_col.desc() + stmt = select(Appointment).where(and_(*clauses)).order_by(order).offset(offset).limit(limit) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_filtered(self, tenant_id: UUID, *, spec: AppointmentListSpec | None = None) -> int: + clauses = self._filter_clauses(tenant_id, spec) + stmt = select(func.count()).select_from(Appointment).where(and_(*clauses)) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + +class WaitingListEntryRepository(TenantBaseRepository[WaitingListEntry]): + model = WaitingListEntry + + async def get_by_code(self, tenant_id: UUID, code: str) -> WaitingListEntry | None: + stmt = select(WaitingListEntry).where( + WaitingListEntry.tenant_id == tenant_id, + WaitingListEntry.code == code, + WaitingListEntry.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/services/healthcare/app/repositories/base.py b/backend/services/healthcare/app/repositories/base.py new file mode 100644 index 0000000..0c6dd9d --- /dev/null +++ b/backend/services/healthcare/app/repositories/base.py @@ -0,0 +1,78 @@ +"""Tenant-aware base repository with soft-delete helpers.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Generic, Sequence, TypeVar +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Base + +ModelT = TypeVar("ModelT", bound=Base) + + +class TenantBaseRepository(Generic[ModelT]): + model: type[ModelT] + + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None: + clauses = [ + self.model.tenant_id == tenant_id, # type: ignore[attr-defined] + self.model.id == entity_id, # type: ignore[attr-defined] + ] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(self.model).where(*clauses) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_including_deleted( + self, tenant_id: UUID, entity_id: UUID + ) -> ModelT | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, # type: ignore[attr-defined] + self.model.id == entity_id, # type: ignore[attr-defined] + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def add(self, entity: ModelT) -> ModelT: + self.session.add(entity) + await self.session.flush() + return entity + + async def soft_delete(self, entity: ModelT, *, deleted_by: str | None = None) -> None: + entity.is_deleted = True # type: ignore[attr-defined] + entity.deleted_at = datetime.now(timezone.utc) # type: ignore[attr-defined] + if deleted_by is not None and hasattr(entity, "deleted_by"): + entity.deleted_by = deleted_by # type: ignore[attr-defined] + await self.session.flush() + + async def restore(self, entity: ModelT) -> None: + entity.is_deleted = False # type: ignore[attr-defined] + entity.deleted_at = None # type: ignore[attr-defined] + if hasattr(entity, "deleted_by"): + entity.deleted_by = None # type: ignore[attr-defined] + await self.session.flush() + + async def list_by_tenant( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[ModelT]: + clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(self.model).where(*clauses).offset(offset).limit(limit) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_by_tenant(self, tenant_id: UUID) -> int: + clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(func.count()).select_from(self.model).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) diff --git a/backend/services/healthcare/app/repositories/clinic_management.py b/backend/services/healthcare/app/repositories/clinic_management.py new file mode 100644 index 0000000..dfb2f3c --- /dev/null +++ b/backend/services/healthcare/app/repositories/clinic_management.py @@ -0,0 +1,62 @@ +"""Clinic management repositories — Phase 13.3.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.clinic_management import ( + ClinicPolicy, + ClinicService, + OperatingHoursPolicy, + StaffAssignment, +) +from app.repositories.base import TenantBaseRepository + + +class ClinicServiceRepository(TenantBaseRepository[ClinicService]): + model = ClinicService + + async def get_by_code(self, tenant_id: UUID, code: str) -> ClinicService | None: + stmt = select(ClinicService).where( + ClinicService.tenant_id == tenant_id, + ClinicService.code == code, + ClinicService.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + +class StaffAssignmentRepository(TenantBaseRepository[StaffAssignment]): + model = StaffAssignment + + async def get_by_code(self, tenant_id: UUID, code: str) -> StaffAssignment | None: + stmt = select(StaffAssignment).where( + StaffAssignment.tenant_id == tenant_id, + StaffAssignment.code == code, + StaffAssignment.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + +class ClinicPolicyRepository(TenantBaseRepository[ClinicPolicy]): + model = ClinicPolicy + + async def get_by_code(self, tenant_id: UUID, code: str) -> ClinicPolicy | None: + stmt = select(ClinicPolicy).where( + ClinicPolicy.tenant_id == tenant_id, + ClinicPolicy.code == code, + ClinicPolicy.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + +class OperatingHoursPolicyRepository(TenantBaseRepository[OperatingHoursPolicy]): + model = OperatingHoursPolicy + + async def get_by_code(self, tenant_id: UUID, code: str) -> OperatingHoursPolicy | None: + stmt = select(OperatingHoursPolicy).where( + OperatingHoursPolicy.tenant_id == tenant_id, + OperatingHoursPolicy.code == code, + OperatingHoursPolicy.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() diff --git a/backend/services/healthcare/app/repositories/delivery_integration.py b/backend/services/healthcare/app/repositories/delivery_integration.py new file mode 100644 index 0000000..da7416a --- /dev/null +++ b/backend/services/healthcare/app/repositories/delivery_integration.py @@ -0,0 +1,65 @@ +"""Delivery integration repositories — Phase 13.7.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.delivery_integration import ( + DeliveryIntegrationRegistration, + DeliveryJobIntent, + DeliveryJobStatusSnapshot, +) +from app.repositories.base import TenantBaseRepository + + +class DeliveryIntegrationRepository(TenantBaseRepository[DeliveryIntegrationRegistration]): + model = DeliveryIntegrationRegistration + + async def get_by_code(self, tenant_id: UUID, code: str) -> DeliveryIntegrationRegistration | None: + stmt = select(DeliveryIntegrationRegistration).where( + DeliveryIntegrationRegistration.tenant_id == tenant_id, + DeliveryIntegrationRegistration.code == code, + DeliveryIntegrationRegistration.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DeliveryJobIntentRepository(TenantBaseRepository[DeliveryJobIntent]): + model = DeliveryJobIntent + + async def get_by_code(self, tenant_id: UUID, code: str) -> DeliveryJobIntent | None: + stmt = select(DeliveryJobIntent).where( + DeliveryJobIntent.tenant_id == tenant_id, + DeliveryJobIntent.code == code, + DeliveryJobIntent.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_idempotency( + self, tenant_id: UUID, idempotency_key: str + ) -> DeliveryJobIntent | None: + stmt = select(DeliveryJobIntent).where( + DeliveryJobIntent.tenant_id == tenant_id, + DeliveryJobIntent.idempotency_key == idempotency_key, + DeliveryJobIntent.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_delivery_ref( + self, tenant_id: UUID, delivery_job_ref: str + ) -> DeliveryJobIntent | None: + stmt = select(DeliveryJobIntent).where( + DeliveryJobIntent.tenant_id == tenant_id, + DeliveryJobIntent.delivery_job_ref == delivery_job_ref, + DeliveryJobIntent.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DeliveryJobStatusSnapshotRepository(TenantBaseRepository[DeliveryJobStatusSnapshot]): + model = DeliveryJobStatusSnapshot diff --git a/backend/services/healthcare/app/repositories/doctor_panel.py b/backend/services/healthcare/app/repositories/doctor_panel.py new file mode 100644 index 0000000..b203603 --- /dev/null +++ b/backend/services/healthcare/app/repositories/doctor_panel.py @@ -0,0 +1,119 @@ +"""Doctor panel repositories — Phase 13.2.""" +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import and_, func, select + +from app.models.doctor_panel import VisitNote, VisitSession +from app.models.types import VisitSessionStatus +from app.repositories.base import TenantBaseRepository +from app.specifications.doctor_panel import VisitSessionListSpec + + +class VisitSessionRepository(TenantBaseRepository[VisitSession]): + model = VisitSession + + async def get_by_code(self, tenant_id: UUID, code: str) -> VisitSession | None: + stmt = select(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.code == code, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_appointment( + self, tenant_id: UUID, appointment_id: UUID + ) -> VisitSession | None: + stmt = select(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.appointment_id == appointment_id, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + def _filter_clauses(self, tenant_id: UUID, spec: VisitSessionListSpec | None): + clauses = [VisitSession.tenant_id == tenant_id, VisitSession.is_deleted.is_(False)] + if spec is None: + return clauses + if spec.doctor_id is not None: + clauses.append(VisitSession.doctor_id == spec.doctor_id) + if spec.clinic_id is not None: + clauses.append(VisitSession.clinic_id == spec.clinic_id) + if spec.status is not None: + clauses.append(VisitSession.status == spec.status) + return clauses + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: VisitSessionListSpec | None = None, + ): + clauses = self._filter_clauses(tenant_id, spec) + sort_col = getattr( + VisitSession, + spec.sort_by if spec else "queue_position", + VisitSession.queue_position, + ) + order = sort_col.asc() if spec and spec.sort_dir == "asc" else sort_col.desc() + stmt = ( + select(VisitSession) + .where(and_(*clauses)) + .order_by(order) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_by_status( + self, tenant_id: UUID, *, doctor_id: UUID, status: VisitSessionStatus + ) -> int: + stmt = select(func.count()).select_from(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.doctor_id == doctor_id, + VisitSession.status == status, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def count_completed_today(self, tenant_id: UUID, *, doctor_id: UUID) -> int: + today = datetime.now(timezone.utc).date() + stmt = select(func.count()).select_from(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.doctor_id == doctor_id, + VisitSession.status == VisitSessionStatus.COMPLETED, + VisitSession.is_deleted.is_(False), + func.date(VisitSession.finished_at) == today, + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + +class VisitNoteRepository(TenantBaseRepository[VisitNote]): + model = VisitNote + + async def get_by_code(self, tenant_id: UUID, code: str) -> VisitNote | None: + stmt = select(VisitNote).where( + VisitNote.tenant_id == tenant_id, + VisitNote.code == code, + VisitNote.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_session(self, tenant_id: UUID, visit_session_id: UUID): + stmt = select(VisitNote).where( + VisitNote.tenant_id == tenant_id, + VisitNote.visit_session_id == visit_session_id, + VisitNote.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/healthcare/app/repositories/foundation.py b/backend/services/healthcare/app/repositories/foundation.py new file mode 100644 index 0000000..15ce843 --- /dev/null +++ b/backend/services/healthcare/app/repositories/foundation.py @@ -0,0 +1,150 @@ +"""Healthcare foundation repositories.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import select + +from app.models.foundation import ( + Branch, + Clinic, + Department, + ExternalProviderConfig, + HealthcareAuditLog, + HealthcareConfiguration, + HealthcarePermission, + HealthcareRole, + HealthcareSetting, +) +from app.repositories.base import TenantBaseRepository + + +class ClinicRepository(TenantBaseRepository[Clinic]): + model = Clinic + + async def get_by_code(self, tenant_id: UUID, code: str) -> Clinic | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class BranchRepository(TenantBaseRepository[Branch]): + model = Branch + + async def list_by_clinic( + self, tenant_id: UUID, clinic_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[Branch]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.clinic_id == clinic_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class DepartmentRepository(TenantBaseRepository[Department]): + model = Department + + async def get_by_code( + self, tenant_id: UUID, clinic_id: UUID, code: str + ) -> Department | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.clinic_id == clinic_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HealthcareRoleRepository(TenantBaseRepository[HealthcareRole]): + model = HealthcareRole + + +class HealthcarePermissionRepository(TenantBaseRepository[HealthcarePermission]): + model = HealthcarePermission + + +class ExternalProviderConfigRepository(TenantBaseRepository[ExternalProviderConfig]): + model = ExternalProviderConfig + + async def get_by_code( + self, tenant_id: UUID, code: str + ) -> ExternalProviderConfig | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HealthcareConfigurationRepository(TenantBaseRepository[HealthcareConfiguration]): + model = HealthcareConfiguration + + +class HealthcareSettingRepository(TenantBaseRepository[HealthcareSetting]): + model = HealthcareSetting + + async def get_by_key( + self, + tenant_id: UUID, + key: str, + *, + clinic_id: UUID | None = None, + branch_id: UUID | None = None, + ) -> HealthcareSetting | None: + clauses = [ + self.model.tenant_id == tenant_id, + self.model.key == key, + self.model.is_deleted.is_(False), + ] + if clinic_id is None: + clauses.append(self.model.clinic_id.is_(None)) + else: + clauses.append(self.model.clinic_id == clinic_id) + if branch_id is None: + clauses.append(self.model.branch_id.is_(None)) + else: + clauses.append(self.model.branch_id == branch_id) + stmt = select(self.model).where(*clauses) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class HealthcareAuditLogRepository(TenantBaseRepository[HealthcareAuditLog]): + model = HealthcareAuditLog + + async def list_for_entity( + self, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + *, + limit: int = 100, + ) -> Sequence[HealthcareAuditLog]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.entity_type == entity_type, + self.model.entity_id == entity_id, + ) + .order_by(self.model.created_at.desc()) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/healthcare/app/repositories/medical_records.py b/backend/services/healthcare/app/repositories/medical_records.py new file mode 100644 index 0000000..d4acddc --- /dev/null +++ b/backend/services/healthcare/app/repositories/medical_records.py @@ -0,0 +1,116 @@ +"""Medical record repositories — Phase 13.5.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.medical_records import ( + Allergy, + Condition, + Encounter, + ImmunizationRecord, + MedicalRecord, + MedicalRecordAccessLog, + MedicationStatement, +) +from app.repositories.base import TenantBaseRepository + + +class MedicalRecordRepository(TenantBaseRepository[MedicalRecord]): + model = MedicalRecord + + async def get_by_code(self, tenant_id: UUID, code: str) -> MedicalRecord | None: + stmt = select(MedicalRecord).where( + MedicalRecord.tenant_id == tenant_id, + MedicalRecord.code == code, + MedicalRecord.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class EncounterRepository(TenantBaseRepository[Encounter]): + model = Encounter + + async def get_by_code(self, tenant_id: UUID, code: str) -> Encounter | None: + stmt = select(Encounter).where( + Encounter.tenant_id == tenant_id, + Encounter.code == code, + Encounter.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(Encounter).where( + Encounter.tenant_id == tenant_id, + Encounter.medical_record_id == medical_record_id, + Encounter.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class ConditionRepository(TenantBaseRepository[Condition]): + model = Condition + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(Condition).where( + Condition.tenant_id == tenant_id, + Condition.medical_record_id == medical_record_id, + Condition.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class AllergyRepository(TenantBaseRepository[Allergy]): + model = Allergy + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(Allergy).where( + Allergy.tenant_id == tenant_id, + Allergy.medical_record_id == medical_record_id, + Allergy.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class MedicationStatementRepository(TenantBaseRepository[MedicationStatement]): + model = MedicationStatement + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(MedicationStatement).where( + MedicationStatement.tenant_id == tenant_id, + MedicationStatement.medical_record_id == medical_record_id, + MedicationStatement.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class ImmunizationRecordRepository(TenantBaseRepository[ImmunizationRecord]): + model = ImmunizationRecord + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(ImmunizationRecord).where( + ImmunizationRecord.tenant_id == tenant_id, + ImmunizationRecord.medical_record_id == medical_record_id, + ImmunizationRecord.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class MedicalRecordAccessLogRepository(TenantBaseRepository[MedicalRecordAccessLog]): + model = MedicalRecordAccessLog + + async def list_by_record(self, tenant_id: UUID, medical_record_id: UUID): + stmt = select(MedicalRecordAccessLog).where( + MedicalRecordAccessLog.tenant_id == tenant_id, + MedicalRecordAccessLog.medical_record_id == medical_record_id, + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/healthcare/app/repositories/patient_portal.py b/backend/services/healthcare/app/repositories/patient_portal.py new file mode 100644 index 0000000..4b391b4 --- /dev/null +++ b/backend/services/healthcare/app/repositories/patient_portal.py @@ -0,0 +1,74 @@ +"""Patient portal repositories — Phase 13.4.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.patient_portal import ( + PatientDocumentRef, + PatientNotificationPreference, + PatientPortalProfile, +) +from app.repositories.base import TenantBaseRepository + + +class PatientPortalProfileRepository(TenantBaseRepository[PatientPortalProfile]): + model = PatientPortalProfile + + async def get_by_patient(self, tenant_id: UUID, patient_id: UUID) -> PatientPortalProfile | None: + stmt = select(PatientPortalProfile).where( + PatientPortalProfile.tenant_id == tenant_id, + PatientPortalProfile.patient_id == patient_id, + PatientPortalProfile.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PatientDocumentRefRepository(TenantBaseRepository[PatientDocumentRef]): + model = PatientDocumentRef + + async def get_by_code(self, tenant_id: UUID, code: str) -> PatientDocumentRef | None: + stmt = select(PatientDocumentRef).where( + PatientDocumentRef.tenant_id == tenant_id, + PatientDocumentRef.code == code, + PatientDocumentRef.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient(self, tenant_id: UUID, patient_id: UUID): + stmt = select(PatientDocumentRef).where( + PatientDocumentRef.tenant_id == tenant_id, + PatientDocumentRef.patient_id == patient_id, + PatientDocumentRef.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class PatientNotificationPreferenceRepository(TenantBaseRepository[PatientNotificationPreference]): + model = PatientNotificationPreference + + async def get_pref( + self, tenant_id: UUID, patient_id: UUID, channel: str, template_key: str + ) -> PatientNotificationPreference | None: + stmt = select(PatientNotificationPreference).where( + PatientNotificationPreference.tenant_id == tenant_id, + PatientNotificationPreference.patient_id == patient_id, + PatientNotificationPreference.channel == channel, + PatientNotificationPreference.template_key == template_key, + PatientNotificationPreference.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_patient(self, tenant_id: UUID, patient_id: UUID): + stmt = select(PatientNotificationPreference).where( + PatientNotificationPreference.tenant_id == tenant_id, + PatientNotificationPreference.patient_id == patient_id, + PatientNotificationPreference.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/healthcare/app/repositories/pharmacy.py b/backend/services/healthcare/app/repositories/pharmacy.py new file mode 100644 index 0000000..07f1305 --- /dev/null +++ b/backend/services/healthcare/app/repositories/pharmacy.py @@ -0,0 +1,52 @@ +"""Pharmacy repositories — Phase 13.6.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.pharmacy import Pharmacy, PrescriptionLine, PrescriptionOrder, PrescriptionStatusHistory +from app.repositories.base import TenantBaseRepository + + +class PharmacyRepository(TenantBaseRepository[Pharmacy]): + model = Pharmacy + + async def get_by_code(self, tenant_id: UUID, code: str) -> Pharmacy | None: + stmt = select(Pharmacy).where( + Pharmacy.tenant_id == tenant_id, + Pharmacy.code == code, + Pharmacy.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PrescriptionOrderRepository(TenantBaseRepository[PrescriptionOrder]): + model = PrescriptionOrder + + async def get_by_code(self, tenant_id: UUID, code: str) -> PrescriptionOrder | None: + stmt = select(PrescriptionOrder).where( + PrescriptionOrder.tenant_id == tenant_id, + PrescriptionOrder.code == code, + PrescriptionOrder.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PrescriptionLineRepository(TenantBaseRepository[PrescriptionLine]): + model = PrescriptionLine + + async def list_by_order(self, tenant_id: UUID, prescription_order_id: UUID): + stmt = select(PrescriptionLine).where( + PrescriptionLine.tenant_id == tenant_id, + PrescriptionLine.prescription_order_id == prescription_order_id, + PrescriptionLine.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class PrescriptionStatusHistoryRepository(TenantBaseRepository[PrescriptionStatusHistory]): + model = PrescriptionStatusHistory diff --git a/backend/services/healthcare/app/repositories/profiles.py b/backend/services/healthcare/app/repositories/profiles.py new file mode 100644 index 0000000..69b6f0f --- /dev/null +++ b/backend/services/healthcare/app/repositories/profiles.py @@ -0,0 +1,53 @@ +"""Doctor and Patient repositories.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.profiles import Doctor, Patient +from app.repositories.base import TenantBaseRepository + + +class DoctorRepository(TenantBaseRepository[Doctor]): + model = Doctor + + async def get_by_code(self, tenant_id: UUID, code: str) -> Doctor | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_user_id(self, tenant_id: UUID, user_id: str) -> Doctor | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.user_id == user_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class PatientRepository(TenantBaseRepository[Patient]): + model = Patient + + async def get_by_code(self, tenant_id: UUID, code: str) -> Patient | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_user_id(self, tenant_id: UUID, user_id: str) -> Patient | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.user_id == user_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() diff --git a/backend/services/healthcare/app/schemas/appointments.py b/backend/services/healthcare/app/schemas/appointments.py new file mode 100644 index 0000000..2881517 --- /dev/null +++ b/backend/services/healthcare/app/schemas/appointments.py @@ -0,0 +1,182 @@ +"""Appointment DTOs — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime, time +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import AppointmentStatus, DayOfWeek, LifecycleStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ScheduleCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + department_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + day_of_week: DayOfWeek + start_time: time + end_time: time + slot_duration_minutes: int = Field(default=30, ge=5, le=480) + is_active: bool = True + metadata_json: dict | None = None + + +class ScheduleRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + department_id: UUID | None + code: str + name: str + day_of_week: DayOfWeek + start_time: time + end_time: time + slot_duration_minutes: int + is_active: bool + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AvailabilityWindowCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + code: str = Field(max_length=50) + starts_at: datetime + ends_at: datetime + is_bookable: bool = True + metadata_json: dict | None = None + + +class AvailabilityWindowRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + code: str + starts_at: datetime + ends_at: datetime + is_bookable: bool + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AppointmentTypeCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + default_duration_minutes: int = Field(default=30, ge=5, le=480) + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class AppointmentTypeRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + code: str + name: str + description: str | None + default_duration_minutes: int + status: LifecycleStatus + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AppointmentCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + department_id: UUID | None = None + appointment_type_id: UUID | None = None + code: str = Field(max_length=50) + scheduled_start: datetime + scheduled_end: datetime + notes: str | None = None + metadata_json: dict | None = None + + +class AppointmentReschedule(BaseModel): + model_config = _FORBID + scheduled_start: datetime + scheduled_end: datetime + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + +class AppointmentLifecycleRequest(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + +class AppointmentRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + department_id: UUID | None + appointment_type_id: UUID | None + code: str + status: AppointmentStatus + scheduled_start: datetime + scheduled_end: datetime + notes: str | None + status_reason: str | None + status_changed_at: datetime | None + reminder_scheduled: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AppointmentListResponse(BaseModel): + items: list[AppointmentRead] + total: int + page: int + page_size: int + + +class WaitingListEntryCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID | None = None + patient_id: UUID + appointment_type_id: UUID | None = None + code: str = Field(max_length=50) + preferred_date: str | None = Field(default=None, max_length=32) + notes: str | None = None + priority: int = Field(default=100, ge=1, le=1000) + + +class WaitingListEntryRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID | None + patient_id: UUID + appointment_type_id: UUID | None + code: str + preferred_date: str | None + notes: str | None + priority: int + is_deleted: bool + created_at: datetime + updated_at: datetime diff --git a/backend/services/healthcare/app/schemas/clinic_management.py b/backend/services/healthcare/app/schemas/clinic_management.py new file mode 100644 index 0000000..fa7abde --- /dev/null +++ b/backend/services/healthcare/app/schemas/clinic_management.py @@ -0,0 +1,126 @@ +"""Clinic management DTOs — Phase 13.3.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ClinicPolicyKind, LifecycleStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ClinicServiceCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + department_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + duration_minutes: int = Field(default=30, ge=5, le=480) + pricing_ref: str | None = Field(default=None, max_length=100) + status: LifecycleStatus = LifecycleStatus.ACTIVE + metadata_json: dict | None = None + + +class ClinicServiceRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + department_id: UUID | None + code: str + name: str + description: str | None + duration_minutes: int + pricing_ref: str | None + status: LifecycleStatus + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class StaffAssignmentCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + department_id: UUID | None = None + doctor_id: UUID + code: str = Field(max_length=50) + role_code: str = Field(max_length=50) + is_primary: bool = False + metadata_json: dict | None = None + + +class StaffAssignmentRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + department_id: UUID | None + doctor_id: UUID + code: str + role_code: str + is_primary: bool + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class ClinicPolicyCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + kind: ClinicPolicyKind + booking_lead_time_hours: int = Field(default=24, ge=0, le=720) + cancellation_window_hours: int = Field(default=12, ge=0, le=720) + privacy_flags: dict | None = None + rules: dict | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class ClinicPolicyRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + code: str + name: str + kind: ClinicPolicyKind + booking_lead_time_hours: int + cancellation_window_hours: int + privacy_flags: dict | None + rules: dict | None + status: LifecycleStatus + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class OperatingHoursPolicyCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + branch_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + weekly_hours: dict + exceptions: dict | None = None + timezone: str = Field(default="Asia/Tehran", max_length=64) + + +class OperatingHoursPolicyRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + branch_id: UUID | None + code: str + name: str + weekly_hours: dict + exceptions: dict | None + timezone: str + is_deleted: bool + created_at: datetime + updated_at: datetime diff --git a/backend/services/healthcare/app/schemas/common.py b/backend/services/healthcare/app/schemas/common.py new file mode 100644 index 0000000..12b4f56 --- /dev/null +++ b/backend/services/healthcare/app/schemas/common.py @@ -0,0 +1,18 @@ +"""Common schemas.""" +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ORMBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class IdResponse(BaseModel): + id: UUID + + +class MessageResponse(BaseModel): + message: str diff --git a/backend/services/healthcare/app/schemas/delivery_integration.py b/backend/services/healthcare/app/schemas/delivery_integration.py new file mode 100644 index 0000000..c886c68 --- /dev/null +++ b/backend/services/healthcare/app/schemas/delivery_integration.py @@ -0,0 +1,77 @@ +"""Delivery integration DTOs — Phase 13.7.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import DeliveryJobStatus, LifecycleStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class DeliveryIntegrationCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID | None = None + pharmacy_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + delivery_merchant_ref: str = Field(max_length=255) + webhook_secret_ref: str | None = Field(default=None, max_length=255) + status: LifecycleStatus = LifecycleStatus.ACTIVE + config: dict | None = None + + +class DeliveryIntegrationRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID | None + pharmacy_id: UUID | None + code: str + name: str + delivery_merchant_ref: str + webhook_secret_ref: str | None + status: LifecycleStatus + config: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class DeliveryJobIntentCreate(BaseModel): + model_config = _FORBID + registration_id: UUID + idempotency_key: str = Field(max_length=100) + code: str = Field(max_length=50) + pickup_address: dict | None = None + dropoff_address: dict | None = None + metadata_json: dict | None = None + + +class DeliveryJobIntentRead(ORMBase): + id: UUID + tenant_id: UUID + registration_id: UUID + prescription_order_id: UUID + code: str + delivery_job_ref: str | None + status: DeliveryJobStatus + idempotency_key: str + pickup_address: dict | None + dropoff_address: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class DeliveryStatusWebhook(BaseModel): + model_config = _FORBID + delivery_job_ref: str = Field(max_length=255) + status: DeliveryJobStatus + external_status: str | None = Field(default=None, max_length=100) + payload: dict | None = None diff --git a/backend/services/healthcare/app/schemas/doctor_panel.py b/backend/services/healthcare/app/schemas/doctor_panel.py new file mode 100644 index 0000000..815208a --- /dev/null +++ b/backend/services/healthcare/app/schemas/doctor_panel.py @@ -0,0 +1,98 @@ +"""Doctor panel DTOs — Phase 13.2.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import VisitSessionStatus +from app.schemas.appointments import AppointmentRead +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class VisitSessionCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + appointment_id: UUID + code: str = Field(max_length=50) + queue_position: int = Field(default=100, ge=1, le=10000) + metadata_json: dict | None = None + + +class VisitSessionActionRequest(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + +class VisitSessionRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + appointment_id: UUID + code: str + status: VisitSessionStatus + queue_position: int + started_at: datetime | None + finished_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class VisitNoteCreate(BaseModel): + model_config = _FORBID + visit_session_id: UUID + doctor_id: UUID + code: str = Field(max_length=50) + title: str = Field(max_length=255) + body: str | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class VisitNoteUpdate(BaseModel): + model_config = _FORBID + title: str | None = Field(default=None, max_length=255) + body: str | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + version: int = Field(ge=1) + metadata_json: dict | None = None + + +class VisitNoteRead(ORMBase): + id: UUID + tenant_id: UUID + visit_session_id: UUID + doctor_id: UUID + code: str + title: str + body: str | None + storage_file_ref: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class DoctorDashboardRead(BaseModel): + doctor_id: UUID + queued_count: int + in_progress_count: int + completed_today_count: int + upcoming_appointments: list[AppointmentRead] + + +class DoctorQueueRead(BaseModel): + doctor_id: UUID + items: list[VisitSessionRead] diff --git a/backend/services/healthcare/app/schemas/foundation.py b/backend/services/healthcare/app/schemas/foundation.py new file mode 100644 index 0000000..95a3cf8 --- /dev/null +++ b/backend/services/healthcare/app/schemas/foundation.py @@ -0,0 +1,284 @@ +"""Healthcare foundation DTOs — Phase 13.0.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import LifecycleStatus, ProviderKind, ProviderStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ClinicCreate(BaseModel): + model_config = _FORBID + + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.DRAFT + legal_name: str | None = Field(default=None, max_length=255) + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + settings: dict | None = None + + +class ClinicUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + legal_name: str | None = Field(default=None, max_length=255) + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + settings: dict | None = None + version: int = Field(ge=1) + + +class ClinicRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + legal_name: str | None + timezone: str + language: str + currency_code: str + settings: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class BranchCreate(BaseModel): + model_config = _FORBID + + clinic_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class BranchUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class BranchRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + address: dict | None + timezone: str | None + working_hours: dict | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DepartmentCreate(BaseModel): + model_config = _FORBID + + clinic_id: UUID + branch_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + specialty: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class DepartmentUpdate(BaseModel): + model_config = _FORBID + + branch_id: UUID | None = None + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + specialty: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class DepartmentRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + branch_id: UUID | None + code: str + name: str + description: str | None + status: LifecycleStatus + specialty: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExternalProviderConfigCreate(BaseModel): + model_config = _FORBID + + clinic_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + provider_kind: ProviderKind = ProviderKind.CUSTOM + adapter_key: str = Field(max_length=100) + status: ProviderStatus = ProviderStatus.REGISTERED + priority: int = Field(default=100, ge=0) + credentials_ref: str | None = Field(default=None, max_length=255) + config: dict | None = None + capabilities: dict | None = None + + +class ExternalProviderConfigUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + provider_kind: ProviderKind | None = None + adapter_key: str | None = Field(default=None, max_length=100) + status: ProviderStatus | None = None + priority: int | None = Field(default=None, ge=0) + credentials_ref: str | None = Field(default=None, max_length=255) + config: dict | None = None + capabilities: dict | None = None + version: int = Field(ge=1) + + +class ExternalProviderConfigRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID | None + code: str + name: str + provider_kind: ProviderKind + adapter_key: str + status: ProviderStatus + priority: int + credentials_ref: str | None + config: dict | None + capabilities: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HealthcareConfigurationCreate(BaseModel): + model_config = _FORBID + + clinic_id: UUID + branch_id: UUID | None = None + code: str = Field(max_length=50) + working_hours: dict | None = None + clinical_policies: dict | None = None + privacy_policies: dict | None = None + custom_fields: dict | None = None + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + + +class HealthcareConfigurationUpdate(BaseModel): + model_config = _FORBID + + branch_id: UUID | None = None + working_hours: dict | None = None + clinical_policies: dict | None = None + privacy_policies: dict | None = None + custom_fields: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + version: int = Field(ge=1) + + +class HealthcareConfigurationRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + branch_id: UUID | None + code: str + working_hours: dict | None + clinical_policies: dict | None + privacy_policies: dict | None + custom_fields: dict | None + timezone: str + language: str + currency_code: str + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HealthcareSettingUpsert(BaseModel): + model_config = _FORBID + + clinic_id: UUID | None = None + branch_id: UUID | None = None + key: str = Field(max_length=100) + value: dict | None = None + description: str | None = None + + +class HealthcareSettingRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID | None + branch_id: UUID | None + key: str + value: dict | None + description: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class HealthcareAuditLogRead(ORMBase): + id: UUID + tenant_id: UUID + entity_type: str + entity_id: UUID + action: str + actor_user_id: str | None + changes: dict | None + message: str | None + created_at: datetime diff --git a/backend/services/healthcare/app/schemas/medical_records.py b/backend/services/healthcare/app/schemas/medical_records.py new file mode 100644 index 0000000..a72857b --- /dev/null +++ b/backend/services/healthcare/app/schemas/medical_records.py @@ -0,0 +1,168 @@ +"""Medical record DTOs — Phase 13.5.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import EncounterStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class MedicalRecordCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + patient_id: UUID + code: str = Field(max_length=50) + access_policy: dict | None = None + metadata_json: dict | None = None + + +class MedicalRecordRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + patient_id: UUID + code: str + access_policy: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class EncounterCreate(BaseModel): + model_config = _FORBID + medical_record_id: UUID + visit_session_id: UUID | None = None + appointment_id: UUID | None = None + doctor_id: UUID + code: str = Field(max_length=50) + chief_complaint: str | None = None + clinical_summary: str | None = None + metadata_json: dict | None = None + + +class EncounterUpdate(BaseModel): + model_config = _FORBID + chief_complaint: str | None = None + clinical_summary: str | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class EncounterFinalize(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + addendum: str | None = None + + +class EncounterRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + visit_session_id: UUID | None + appointment_id: UUID | None + doctor_id: UUID + code: str + status: EncounterStatus + chief_complaint: str | None + clinical_summary: str | None + finalized_at: datetime | None + addendum: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class ClinicalItemCreate(BaseModel): + model_config = _FORBID + code: str = Field(max_length=50) + metadata_json: dict | None = None + + +class ConditionCreate(ClinicalItemCreate): + display: str = Field(max_length=255) + clinical_status: str | None = Field(default=None, max_length=50) + + +class AllergyCreate(ClinicalItemCreate): + substance: str = Field(max_length=255) + severity: str | None = Field(default=None, max_length=50) + + +class MedicationCreate(ClinicalItemCreate): + medication_code: str = Field(max_length=100) + display: str = Field(max_length=255) + dosage: str | None = Field(default=None, max_length=100) + + +class ImmunizationCreate(ClinicalItemCreate): + vaccine_code: str = Field(max_length=100) + administered_on: str | None = Field(default=None, max_length=32) + storage_file_ref: str | None = Field(default=None, max_length=255) + + +class ConditionRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + code: str + display: str + clinical_status: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AllergyRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + code: str + substance: str + severity: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class MedicationRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + code: str + medication_code: str + display: str + dosage: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class ImmunizationRead(ORMBase): + id: UUID + tenant_id: UUID + medical_record_id: UUID + code: str + vaccine_code: str + administered_on: str | None + storage_file_ref: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class MedicalRecordAccessRequest(BaseModel): + model_config = _FORBID + reason: str | None = Field(default=None, max_length=500) diff --git a/backend/services/healthcare/app/schemas/patient_portal.py b/backend/services/healthcare/app/schemas/patient_portal.py new file mode 100644 index 0000000..4cf8182 --- /dev/null +++ b/backend/services/healthcare/app/schemas/patient_portal.py @@ -0,0 +1,99 @@ +"""Patient portal DTOs — Phase 13.4.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.schemas.appointments import AppointmentCreate, AppointmentLifecycleRequest, AppointmentRead +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class PatientPortalProfileCreate(BaseModel): + model_config = _FORBID + patient_id: UUID + preferred_clinic_id: UUID | None = None + preferred_language: str = Field(default="fa", max_length=16) + emergency_contact_ref: str | None = Field(default=None, max_length=255) + notification_prefs: dict | None = None + metadata_json: dict | None = None + + +class PatientPortalProfileUpdate(BaseModel): + model_config = _FORBID + preferred_clinic_id: UUID | None = None + preferred_language: str | None = Field(default=None, max_length=16) + emergency_contact_ref: str | None = Field(default=None, max_length=255) + notification_prefs: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class PatientPortalProfileRead(ORMBase): + id: UUID + tenant_id: UUID + patient_id: UUID + preferred_clinic_id: UUID | None + preferred_language: str + emergency_contact_ref: str | None + notification_prefs: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PatientDocumentRefCreate(BaseModel): + model_config = _FORBID + code: str = Field(max_length=50) + title: str = Field(max_length=255) + storage_file_ref: str = Field(max_length=255) + document_type: str | None = Field(default=None, max_length=50) + metadata_json: dict | None = None + + +class PatientDocumentRefRead(ORMBase): + id: UUID + tenant_id: UUID + patient_id: UUID + code: str + title: str + storage_file_ref: str + document_type: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PatientNotificationPreferenceUpsert(BaseModel): + model_config = _FORBID + channel: str = Field(max_length=32) + template_key: str = Field(max_length=100) + enabled: bool = True + metadata_json: dict | None = None + + +class PatientNotificationPreferenceRead(ORMBase): + id: UUID + tenant_id: UUID + patient_id: UUID + channel: str + template_key: str + enabled: bool + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PatientPortalAppointmentBook(AppointmentCreate): + pass + + +class PatientPortalAppointmentCancel(AppointmentLifecycleRequest): + pass diff --git a/backend/services/healthcare/app/schemas/pharmacy.py b/backend/services/healthcare/app/schemas/pharmacy.py new file mode 100644 index 0000000..1aef53a --- /dev/null +++ b/backend/services/healthcare/app/schemas/pharmacy.py @@ -0,0 +1,102 @@ +"""Pharmacy network DTOs — Phase 13.6.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import LifecycleStatus, PrescriptionOrderStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class PharmacyCreate(BaseModel): + model_config = _FORBID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + contact: dict | None = None + hours: dict | None = None + service_area_ref: str | None = Field(default=None, max_length=255) + status: LifecycleStatus = LifecycleStatus.ACTIVE + metadata_json: dict | None = None + + +class PharmacyRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + contact: dict | None + hours: dict | None + service_area_ref: str | None + status: LifecycleStatus + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PrescriptionLineCreate(BaseModel): + model_config = _FORBID + line_no: int = Field(ge=1) + medication_code: str = Field(max_length=100) + display: str = Field(max_length=255) + quantity: int = Field(default=1, ge=1) + dosage_instructions: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class PrescriptionOrderCreate(BaseModel): + model_config = _FORBID + pharmacy_id: UUID + encounter_id: UUID | None = None + visit_session_id: UUID | None = None + patient_id: UUID + doctor_id: UUID + code: str = Field(max_length=50) + notes: str | None = None + lines: list[PrescriptionLineCreate] + metadata_json: dict | None = None + + +class PrescriptionLifecycleRequest(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + +class PrescriptionLineRead(ORMBase): + id: UUID + tenant_id: UUID + prescription_order_id: UUID + line_no: int + medication_code: str + display: str + quantity: int + dosage_instructions: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PrescriptionOrderRead(ORMBase): + id: UUID + tenant_id: UUID + pharmacy_id: UUID + encounter_id: UUID | None + visit_session_id: UUID | None + patient_id: UUID + doctor_id: UUID + code: str + status: PrescriptionOrderStatus + status_changed_at: datetime | None + notes: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime diff --git a/backend/services/healthcare/app/schemas/profiles.py b/backend/services/healthcare/app/schemas/profiles.py new file mode 100644 index 0000000..e3efa64 --- /dev/null +++ b/backend/services/healthcare/app/schemas/profiles.py @@ -0,0 +1,109 @@ +"""Doctor and Patient DTOs — Phase 13.0.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ProfileStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class DoctorCreate(BaseModel): + model_config = _FORBID + + user_id: str = Field(max_length=100) + clinic_id: UUID | None = None + department_id: UUID | None = None + code: str = Field(max_length=50) + display_name: str = Field(max_length=255) + specialty: str | None = Field(default=None, max_length=100) + license_number: str | None = Field(default=None, max_length=100) + status: ProfileStatus = ProfileStatus.ACTIVE + contact: dict | None = None + metadata_json: dict | None = None + + +class DoctorUpdate(BaseModel): + model_config = _FORBID + + clinic_id: UUID | None = None + department_id: UUID | None = None + display_name: str | None = Field(default=None, max_length=255) + specialty: str | None = Field(default=None, max_length=100) + license_number: str | None = Field(default=None, max_length=100) + status: ProfileStatus | None = None + contact: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class DoctorRead(ORMBase): + id: UUID + tenant_id: UUID + user_id: str + clinic_id: UUID | None + department_id: UUID | None + code: str + display_name: str + specialty: str | None + license_number: str | None + status: ProfileStatus + contact: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PatientCreate(BaseModel): + model_config = _FORBID + + user_id: str = Field(max_length=100) + code: str = Field(max_length=50) + display_name: str = Field(max_length=255) + status: ProfileStatus = ProfileStatus.ACTIVE + date_of_birth: str | None = Field(default=None, max_length=32) + gender: str | None = Field(default=None, max_length=32) + contact: dict | None = None + emergency_contact: dict | None = None + metadata_json: dict | None = None + + +class PatientUpdate(BaseModel): + model_config = _FORBID + + display_name: str | None = Field(default=None, max_length=255) + status: ProfileStatus | None = None + date_of_birth: str | None = Field(default=None, max_length=32) + gender: str | None = Field(default=None, max_length=32) + contact: dict | None = None + emergency_contact: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class PatientRead(ORMBase): + id: UUID + tenant_id: UUID + user_id: str + code: str + display_name: str + status: ProfileStatus + date_of_birth: str | None + gender: str | None + contact: dict | None + emergency_contact: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/healthcare/app/services/appointments.py b/backend/services/healthcare/app/services/appointments.py new file mode 100644 index 0000000..2ba8d64 --- /dev/null +++ b/backend/services/healthcare/app/services/appointments.py @@ -0,0 +1,485 @@ +"""Appointment application services — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime, timezone +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.appointments import ( + Appointment, + AppointmentType, + AvailabilityWindow, + Schedule, + WaitingListEntry, +) +from app.models.types import AppointmentLifecycleAction, AppointmentStatus, AuditAction +from app.policies.appointments import AppointmentLifecyclePolicy +from app.providers.mocks import MockCommunicationProvider +from app.repositories.appointments import ( + AppointmentRepository, + AppointmentTypeRepository, + AvailabilityWindowRepository, + ScheduleRepository, + WaitingListEntryRepository, +) +from app.repositories.foundation import ClinicRepository, DepartmentRepository +from app.repositories.profiles import DoctorRepository, PatientRepository +from app.schemas.appointments import ( + AppointmentCreate, + AppointmentLifecycleRequest, + AppointmentReschedule, + AppointmentTypeCreate, + AvailabilityWindowCreate, + ScheduleCreate, + WaitingListEntryCreate, +) +from app.services.audit_service import AuditService +from app.services.foundation import _actor_id, _jsonable_changes +from app.specifications.appointments import AppointmentListSpec +from app.validators import ensure_optimistic_version, validate_code +from app.validators.appointments import validate_schedule_times, validate_time_window +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class ScheduleService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ScheduleRepository(session) + self.clinics = ClinicRepository(session) + self.doctors = DoctorRepository(session) + self.departments = DepartmentRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, tenant_id: UUID, body: ScheduleCreate, *, actor: CurrentUser | None = None + ) -> Schedule: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + if body.department_id and await self.departments.get(tenant_id, body.department_id) is None: + raise NotFoundError("بخش یافت نشد") + code = validate_code(body.code) + validate_schedule_times(start_time=body.start_time, end_time=body.end_time) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = Schedule( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + department_id=body.department_id, + code=code, + name=body.name, + day_of_week=body.day_of_week, + start_time=body.start_time, + end_time=body.end_time, + slot_duration_minutes=body.slot_duration_minutes, + is_active=body.is_active, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + self.events.publish( + event_type=HealthcareEventType.SCHEDULE_CREATED, + aggregate_type="schedule", + 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 list(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20) -> list[Schedule]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + +class AvailabilityWindowService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = AvailabilityWindowRepository(session) + self.clinics = ClinicRepository(session) + self.doctors = DoctorRepository(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: AvailabilityWindowCreate, + *, + actor: CurrentUser | None = None, + ) -> AvailabilityWindow: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + code = validate_code(body.code) + validate_time_window(starts_at=body.starts_at, ends_at=body.ends_at) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = AvailabilityWindow( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + code=code, + starts_at=body.starts_at, + ends_at=body.ends_at, + is_bookable=body.is_bookable, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + 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[AvailabilityWindow]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + +class AppointmentTypeService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = AppointmentTypeRepository(session) + self.clinics = ClinicRepository(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: AppointmentTypeCreate, + *, + actor: CurrentUser | None = None, + ) -> AppointmentType: + if await self.clinics.get(tenant_id, body.clinic_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 = AppointmentType( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + code=code, + name=body.name, + description=body.description, + default_duration_minutes=body.default_duration_minutes, + status=body.status, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + 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[AppointmentType]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + +class AppointmentService: + def __init__( + self, + session: AsyncSession, + *, + communication: MockCommunicationProvider | None = None, + ) -> None: + self.session = session + self.repo = AppointmentRepository(session) + self.clinics = ClinicRepository(session) + self.doctors = DoctorRepository(session) + self.patients = PatientRepository(session) + self.departments = DepartmentRepository(session) + self.types = AppointmentTypeRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.policy = AppointmentLifecyclePolicy() + self.communication = communication or MockCommunicationProvider() + + async def _validate_refs(self, tenant_id: UUID, body: AppointmentCreate) -> None: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + if body.department_id and await self.departments.get(tenant_id, body.department_id) is None: + raise NotFoundError("بخش یافت نشد") + if body.appointment_type_id and await self.types.get(tenant_id, body.appointment_type_id) is None: + raise NotFoundError("نوع نوبت یافت نشد") + + async def _ensure_no_conflict( + self, + tenant_id: UUID, + *, + doctor_id: UUID, + scheduled_start: datetime, + scheduled_end: datetime, + exclude_id: UUID | None = None, + ) -> None: + conflicts = await self.repo.find_conflicts( + tenant_id, + doctor_id=doctor_id, + scheduled_start=scheduled_start, + scheduled_end=scheduled_end, + exclude_id=exclude_id, + ) + if conflicts: + raise AppError( + "تداخل زمانی با نوبت دیگر", + status_code=409, + error_code="appointment_conflict", + details={"conflicting_ids": [str(c.id) for c in conflicts]}, + ) + + async def book( + self, tenant_id: UUID, body: AppointmentCreate, *, actor: CurrentUser | None = None + ) -> Appointment: + validate_time_window(starts_at=body.scheduled_start, ends_at=body.scheduled_end) + await self._validate_refs(tenant_id, body) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + await self._ensure_no_conflict( + tenant_id, + doctor_id=body.doctor_id, + scheduled_start=body.scheduled_start, + scheduled_end=body.scheduled_end, + ) + entity = Appointment( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + patient_id=body.patient_id, + department_id=body.department_id, + appointment_type_id=body.appointment_type_id, + code=code, + status=AppointmentStatus.DRAFT, + scheduled_start=body.scheduled_start, + scheduled_end=body.scheduled_end, + notes=body.notes, + metadata_json=body.metadata_json, + status_changed_at=datetime.now(timezone.utc), + 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="appointment", + 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.APPOINTMENT_BOOKED, + aggregate_type="appointment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, appointment_id: UUID) -> Appointment: + entity = await self.repo.get(tenant_id, appointment_id) + if entity is None: + raise NotFoundError("نوبت یافت نشد") + return entity + + async def list( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: AppointmentListSpec | None = None, + ) -> tuple[list[Appointment], int]: + items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec)) + total = await self.repo.count_filtered(tenant_id, spec=spec) + return items, total + + async def _apply_lifecycle( + self, + tenant_id: UUID, + appointment_id: UUID, + action: AppointmentLifecycleAction, + body: AppointmentLifecycleRequest, + *, + actor: CurrentUser | None = None, + event_type: HealthcareEventType, + ) -> Appointment: + entity = await self.get(tenant_id, appointment_id) + ensure_optimistic_version(entity, body.version) + new_status = self.policy.ensure_transition(action=action, current=entity.status) + entity.status = new_status + entity.status_reason = body.reason + entity.status_changed_at = datetime.now(timezone.utc) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="appointment", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"action": action.value, "status": new_status.value}, + ) + self.events.publish( + event_type=event_type, + aggregate_type="appointment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": new_status.value}, + ) + if action == AppointmentLifecycleAction.CONFIRM and not entity.reminder_scheduled: + patient = await self.patients.get(tenant_id, entity.patient_id) + to = (patient.contact or {}).get("phone", "unknown") if patient else "unknown" + await self.communication.send( + tenant_id=tenant_id, + channel="sms", + template_key="healthcare.appointment.reminder", + to=to, + payload={"appointment_id": str(entity.id), "code": entity.code}, + ) + entity.reminder_scheduled = True + self.events.publish( + event_type=HealthcareEventType.APPOINTMENT_REMINDER_SCHEDULED, + aggregate_type="appointment", + 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 confirm( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor=None + ) -> Appointment: + return await self._apply_lifecycle( + tenant_id, + appointment_id, + AppointmentLifecycleAction.CONFIRM, + body, + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_CONFIRMED, + ) + + async def cancel( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor=None + ) -> Appointment: + return await self._apply_lifecycle( + tenant_id, + appointment_id, + AppointmentLifecycleAction.CANCEL, + body, + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_CANCELLED, + ) + + async def mark_no_show( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentLifecycleRequest, *, actor=None + ) -> Appointment: + return await self._apply_lifecycle( + tenant_id, + appointment_id, + AppointmentLifecycleAction.MARK_NO_SHOW, + body, + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_NO_SHOW, + ) + + async def reschedule( + self, tenant_id: UUID, appointment_id: UUID, body: AppointmentReschedule, *, actor=None + ) -> Appointment: + entity = await self.get(tenant_id, appointment_id) + ensure_optimistic_version(entity, body.version) + self.policy.ensure_transition( + action=AppointmentLifecycleAction.RESCHEDULE, current=entity.status + ) + validate_time_window(starts_at=body.scheduled_start, ends_at=body.scheduled_end) + await self._ensure_no_conflict( + tenant_id, + doctor_id=entity.doctor_id, + scheduled_start=body.scheduled_start, + scheduled_end=body.scheduled_end, + exclude_id=entity.id, + ) + entity.scheduled_start = body.scheduled_start + entity.scheduled_end = body.scheduled_end + entity.status_reason = body.reason + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.APPOINTMENT_RESCHEDULED, + aggregate_type="appointment", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class WaitingListService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = WaitingListEntryRepository(session) + self.clinics = ClinicRepository(session) + self.patients = PatientRepository(session) + + async def create( + self, + tenant_id: UUID, + body: WaitingListEntryCreate, + *, + actor: CurrentUser | None = None, + ) -> WaitingListEntry: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.patients.get(tenant_id, body.patient_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 = WaitingListEntry( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + patient_id=body.patient_id, + appointment_type_id=body.appointment_type_id, + code=code, + preferred_date=body.preferred_date, + notes=body.notes, + priority=body.priority, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + 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[WaitingListEntry]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/healthcare/app/services/audit_service.py b/backend/services/healthcare/app/services/audit_service.py new file mode 100644 index 0000000..c2c3208 --- /dev/null +++ b/backend/services/healthcare/app/services/audit_service.py @@ -0,0 +1,51 @@ +"""Audit service — append-only Delivery audit trail.""" +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.foundation import HealthcareAuditLog +from app.models.types import AuditAction +from app.repositories.foundation import HealthcareAuditLogRepository + + +class AuditService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = HealthcareAuditLogRepository(session) + + async def record( + self, + *, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + action: AuditAction, + actor_user_id: str | None = None, + changes: dict[str, Any] | None = None, + message: str | None = None, + commit: bool = False, + ) -> HealthcareAuditLog: + entry = HealthcareAuditLog( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + action=action, + actor_user_id=actor_user_id, + changes=changes, + message=message, + ) + await self.repo.add(entry) + if commit: + await self.session.commit() + await self.session.refresh(entry) + return entry + + async def list_for_entity( + self, tenant_id: UUID, entity_type: str, entity_id: UUID, *, limit: int = 100 + ): + return await self.repo.list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/healthcare/app/services/clinic_management.py b/backend/services/healthcare/app/services/clinic_management.py new file mode 100644 index 0000000..c71c0d9 --- /dev/null +++ b/backend/services/healthcare/app/services/clinic_management.py @@ -0,0 +1,215 @@ +"""Clinic management application services — Phase 13.3.""" +from __future__ import annotations + +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.clinic_management import ( + ClinicPolicy, + ClinicService, + OperatingHoursPolicy, + StaffAssignment, +) +from app.repositories.clinic_management import ( + ClinicPolicyRepository, + ClinicServiceRepository, + OperatingHoursPolicyRepository, + StaffAssignmentRepository, +) +from app.repositories.foundation import BranchRepository, ClinicRepository, DepartmentRepository +from app.repositories.profiles import DoctorRepository +from app.schemas.clinic_management import ( + ClinicPolicyCreate, + ClinicServiceCreate, + OperatingHoursPolicyCreate, + StaffAssignmentCreate, +) +from app.services.foundation import _actor_id +from app.validators import validate_code +from app.validators.clinic_management import validate_clinic_policy_windows, validate_weekly_hours +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class ClinicManagementService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.services = ClinicServiceRepository(session) + self.staff = StaffAssignmentRepository(session) + self.policies = ClinicPolicyRepository(session) + self.hours = OperatingHoursPolicyRepository(session) + self.clinics = ClinicRepository(session) + self.branches = BranchRepository(session) + self.departments = DepartmentRepository(session) + self.doctors = DoctorRepository(session) + self.events = get_event_publisher() + + async def create_service( + self, tenant_id: UUID, body: ClinicServiceCreate, *, actor: CurrentUser | None = None + ) -> ClinicService: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if body.department_id and await self.departments.get(tenant_id, body.department_id) is None: + raise NotFoundError("بخش یافت نشد") + code = validate_code(body.code) + if await self.services.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = ClinicService( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + department_id=body.department_id, + code=code, + name=body.name, + description=body.description, + duration_minutes=body.duration_minutes, + pricing_ref=body.pricing_ref, + status=body.status, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.services.add(entity) + except IntegrityError as exc: + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) from exc + self.events.publish( + event_type=HealthcareEventType.CLINIC_SERVICE_CREATED, + aggregate_type="clinic_service", + 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_staff_assignment( + self, tenant_id: UUID, body: StaffAssignmentCreate, *, actor: CurrentUser | None = None + ) -> StaffAssignment: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + code = validate_code(body.code) + if await self.staff.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = StaffAssignment( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + department_id=body.department_id, + doctor_id=body.doctor_id, + code=code, + role_code=body.role_code, + is_primary=body.is_primary, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.staff.add(entity) + self.events.publish( + event_type=HealthcareEventType.STAFF_ASSIGNMENT_CREATED, + aggregate_type="staff_assignment", + 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_policy( + self, tenant_id: UUID, body: ClinicPolicyCreate, *, actor: CurrentUser | None = None + ) -> ClinicPolicy: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + validate_clinic_policy_windows( + booking_lead_time_hours=body.booking_lead_time_hours, + cancellation_window_hours=body.cancellation_window_hours, + ) + code = validate_code(body.code) + if await self.policies.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = ClinicPolicy( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + code=code, + name=body.name, + kind=body.kind, + booking_lead_time_hours=body.booking_lead_time_hours, + cancellation_window_hours=body.cancellation_window_hours, + privacy_flags=body.privacy_flags, + rules=body.rules, + status=body.status, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.policies.add(entity) + self.events.publish( + event_type=HealthcareEventType.CLINIC_POLICY_CREATED, + aggregate_type="clinic_policy", + 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_operating_hours( + self, + tenant_id: UUID, + body: OperatingHoursPolicyCreate, + *, + actor: CurrentUser | None = None, + ) -> OperatingHoursPolicy: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if body.branch_id and await self.branches.get(tenant_id, body.branch_id) is None: + raise NotFoundError("شعبه یافت نشد") + validate_weekly_hours(body.weekly_hours) + code = validate_code(body.code) + if await self.hours.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = OperatingHoursPolicy( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + branch_id=body.branch_id, + code=code, + name=body.name, + weekly_hours=body.weekly_hours, + exceptions=body.exceptions, + timezone=body.timezone, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.hours.add(entity) + self.events.publish( + event_type=HealthcareEventType.OPERATING_HOURS_POLICY_CREATED, + aggregate_type="operating_hours_policy", + 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 list_services( + self, tenant_id: UUID, *, clinic_id: UUID | None = None, offset: int = 0, limit: int = 20 + ): + if clinic_id: + return list(await self.services.list_by_clinic(tenant_id, clinic_id, offset=offset, limit=limit)) + return list(await self.services.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_staff(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.staff.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_policies(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.policies.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_operating_hours(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.hours.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/healthcare/app/services/delivery_integration.py b/backend/services/healthcare/app/services/delivery_integration.py new file mode 100644 index 0000000..d9b8cc4 --- /dev/null +++ b/backend/services/healthcare/app/services/delivery_integration.py @@ -0,0 +1,199 @@ +"""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)) diff --git a/backend/services/healthcare/app/services/doctor_panel.py b/backend/services/healthcare/app/services/doctor_panel.py new file mode 100644 index 0000000..e0203e7 --- /dev/null +++ b/backend/services/healthcare/app/services/doctor_panel.py @@ -0,0 +1,390 @@ +"""Doctor panel application services — Phase 13.2.""" +from __future__ import annotations + +from datetime import datetime, timezone +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.doctor_panel import VisitNote, VisitSession +from app.models.types import ( + AppointmentLifecycleAction, + AppointmentStatus, + AuditAction, + VisitSessionAction, + VisitSessionStatus, +) +from app.policies.doctor_panel import VisitSessionPolicy +from app.repositories.appointments import AppointmentRepository +from app.repositories.doctor_panel import VisitNoteRepository, VisitSessionRepository +from app.repositories.foundation import ClinicRepository +from app.repositories.profiles import DoctorRepository, PatientRepository +from app.schemas.appointments import AppointmentLifecycleRequest +from app.schemas.doctor_panel import ( + DoctorDashboardRead, + DoctorQueueRead, + VisitNoteCreate, + VisitNoteUpdate, + VisitSessionActionRequest, + VisitSessionCreate, +) +from app.services.appointments import AppointmentService +from app.services.audit_service import AuditService +from app.services.foundation import _actor_id, _jsonable_changes +from app.specifications.appointments import AppointmentListSpec +from app.specifications.doctor_panel import VisitSessionListSpec +from app.validators import ensure_optimistic_version, validate_code +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class VisitSessionService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = VisitSessionRepository(session) + self.appointments = AppointmentRepository(session) + self.appointment_service = AppointmentService(session) + self.clinics = ClinicRepository(session) + self.doctors = DoctorRepository(session) + self.patients = PatientRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.policy = VisitSessionPolicy() + + async def _validate_refs(self, tenant_id: UUID, body: VisitSessionCreate) -> None: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + appt = await self.appointments.get(tenant_id, body.appointment_id) + if appt is None: + raise NotFoundError("نوبت یافت نشد") + if appt.doctor_id != body.doctor_id or appt.patient_id != body.patient_id: + raise AppError( + "نوبت با پزشک/بیمار مطابقت ندارد", + status_code=422, + error_code="appointment_mismatch", + ) + + async def create( + self, tenant_id: UUID, body: VisitSessionCreate, *, actor: CurrentUser | None = None + ) -> VisitSession: + await self._validate_refs(tenant_id, body) + if await self.repo.get_by_appointment(tenant_id, body.appointment_id): + raise AppError( + "جلسه ویزیت برای این نوبت وجود دارد", + status_code=409, + error_code="visit_session_exists", + ) + 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 = VisitSession( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + doctor_id=body.doctor_id, + patient_id=body.patient_id, + appointment_id=body.appointment_id, + code=code, + status=VisitSessionStatus.QUEUED, + queue_position=body.queue_position, + 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="visit_session", + 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.VISIT_SESSION_CREATED, + aggregate_type="visit_session", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "doctor_id": str(entity.doctor_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, visit_session_id: UUID) -> VisitSession: + entity = await self.repo.get(tenant_id, visit_session_id) + if entity is None: + raise NotFoundError("جلسه ویزیت یافت نشد") + return entity + + async def list( + self, + tenant_id: UUID, + *, + offset: int = 0, + limit: int = 20, + spec: VisitSessionListSpec | None = None, + ) -> list[VisitSession]: + return list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec)) + + async def _apply_action( + self, + tenant_id: UUID, + visit_session_id: UUID, + action: VisitSessionAction, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + event_type: HealthcareEventType, + ) -> VisitSession: + entity = await self.get(tenant_id, visit_session_id) + ensure_optimistic_version(entity, body.version) + new_status = self.policy.ensure_transition(action=action, current=entity.status) + now = datetime.now(timezone.utc) + if action == VisitSessionAction.START: + entity.started_at = now + appt = await self.appointments.get(tenant_id, entity.appointment_id) + if appt and appt.status == AppointmentStatus.CONFIRMED: + await self.appointment_service._apply_lifecycle( + tenant_id, + entity.appointment_id, + AppointmentLifecycleAction.CHECK_IN, + AppointmentLifecycleRequest(version=appt.version), + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_CHECKED_IN, + ) + elif action == VisitSessionAction.FINISH: + entity.finished_at = now + appt = await self.appointments.get(tenant_id, entity.appointment_id) + if appt and appt.status in ( + AppointmentStatus.CONFIRMED, + AppointmentStatus.CHECKED_IN, + ): + await self.appointment_service._apply_lifecycle( + tenant_id, + entity.appointment_id, + AppointmentLifecycleAction.COMPLETE, + AppointmentLifecycleRequest(version=appt.version), + actor=actor, + event_type=HealthcareEventType.APPOINTMENT_COMPLETED, + ) + entity.status = new_status + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="visit_session", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"action": action.value, "status": new_status.value}, + ) + self.events.publish( + event_type=event_type, + aggregate_type="visit_session", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": new_status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def start( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ) -> VisitSession: + return await self._apply_action( + tenant_id, + visit_session_id, + VisitSessionAction.START, + body, + actor=actor, + event_type=HealthcareEventType.VISIT_SESSION_STARTED, + ) + + async def finish( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ) -> VisitSession: + return await self._apply_action( + tenant_id, + visit_session_id, + VisitSessionAction.FINISH, + body, + actor=actor, + event_type=HealthcareEventType.VISIT_SESSION_FINISHED, + ) + + async def defer( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ) -> VisitSession: + return await self._apply_action( + tenant_id, + visit_session_id, + VisitSessionAction.DEFER, + body, + actor=actor, + event_type=HealthcareEventType.VISIT_SESSION_DEFERRED, + ) + + async def call_next( + self, + tenant_id: UUID, + visit_session_id: UUID, + body: VisitSessionActionRequest, + *, + actor: CurrentUser | None = None, + ) -> VisitSession: + entity = await self.get(tenant_id, visit_session_id) + ensure_optimistic_version(entity, body.version) + self.policy.ensure_transition(action=VisitSessionAction.CALL_NEXT, current=entity.status) + entity.queue_position = 1 + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.VISIT_SESSION_CALLED, + aggregate_type="visit_session", + 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 dashboard(self, tenant_id: UUID, *, doctor_id: UUID) -> DoctorDashboardRead: + if await self.doctors.get(tenant_id, doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + queued = await self.repo.count_by_status( + tenant_id, doctor_id=doctor_id, status=VisitSessionStatus.QUEUED + ) + in_progress = await self.repo.count_by_status( + tenant_id, doctor_id=doctor_id, status=VisitSessionStatus.IN_PROGRESS + ) + completed_today = await self.repo.count_completed_today(tenant_id, doctor_id=doctor_id) + appt_spec = AppointmentListSpec(doctor_id=doctor_id, sort_by="scheduled_start", sort_dir="asc") + upcoming, _ = await self.appointment_service.list( + tenant_id, offset=0, limit=10, spec=appt_spec + ) + return DoctorDashboardRead( + doctor_id=doctor_id, + queued_count=queued, + in_progress_count=in_progress, + completed_today_count=completed_today, + upcoming_appointments=upcoming, + ) + + async def queue(self, tenant_id: UUID, *, doctor_id: UUID) -> DoctorQueueRead: + spec = VisitSessionListSpec( + doctor_id=doctor_id, + status=VisitSessionStatus.QUEUED, + sort_by="queue_position", + sort_dir="asc", + ) + items = await self.list(tenant_id, offset=0, limit=100, spec=spec) + return DoctorQueueRead(doctor_id=doctor_id, items=items) + + +class VisitNoteService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = VisitNoteRepository(session) + self.sessions = VisitSessionRepository(session) + self.doctors = DoctorRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, tenant_id: UUID, body: VisitNoteCreate, *, actor: CurrentUser | None = None + ) -> VisitNote: + if await self.sessions.get(tenant_id, body.visit_session_id) is None: + raise NotFoundError("جلسه ویزیت یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_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 = VisitNote( + tenant_id=tenant_id, + visit_session_id=body.visit_session_id, + doctor_id=body.doctor_id, + code=code, + title=body.title, + body=body.body, + storage_file_ref=body.storage_file_ref, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + self.events.publish( + event_type=HealthcareEventType.VISIT_NOTE_CREATED, + aggregate_type="visit_note", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "visit_session_id": str(entity.visit_session_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def update( + self, + tenant_id: UUID, + note_id: UUID, + body: VisitNoteUpdate, + *, + actor: CurrentUser | None = None, + ) -> VisitNote: + entity = await self.repo.get(tenant_id, note_id) + if entity is None: + raise NotFoundError("یادداشت ویزیت یافت نشد") + ensure_optimistic_version(entity, body.version) + if body.title is not None: + entity.title = body.title + if body.body is not None: + entity.body = body.body + if body.storage_file_ref is not None: + entity.storage_file_ref = body.storage_file_ref + if body.metadata_json is not None: + entity.metadata_json = body.metadata_json + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.VISIT_NOTE_UPDATED, + aggregate_type="visit_note", + 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 list_by_session(self, tenant_id: UUID, visit_session_id: UUID) -> list[VisitNote]: + if await self.sessions.get(tenant_id, visit_session_id) is None: + raise NotFoundError("جلسه ویزیت یافت نشد") + return list(await self.repo.list_by_session(tenant_id, visit_session_id)) diff --git a/backend/services/healthcare/app/services/foundation.py b/backend/services/healthcare/app/services/foundation.py new file mode 100644 index 0000000..ea7fb8a --- /dev/null +++ b/backend/services/healthcare/app/services/foundation.py @@ -0,0 +1,786 @@ +"""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)) diff --git a/backend/services/healthcare/app/services/medical_records.py b/backend/services/healthcare/app/services/medical_records.py new file mode 100644 index 0000000..f6fd13f --- /dev/null +++ b/backend/services/healthcare/app/services/medical_records.py @@ -0,0 +1,334 @@ +"""Medical record application services — Phase 13.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 get_event_publisher +from app.events.types import HealthcareEventType +from app.models.medical_records import ( + Allergy, + Condition, + Encounter, + ImmunizationRecord, + MedicalRecord, + MedicalRecordAccessLog, + MedicationStatement, +) +from app.models.types import EncounterStatus +from app.policies.medical_records import MedicalRecordAccessPolicy +from app.repositories.foundation import ClinicRepository +from app.repositories.medical_records import ( + AllergyRepository, + ConditionRepository, + EncounterRepository, + ImmunizationRecordRepository, + MedicalRecordAccessLogRepository, + MedicalRecordRepository, + MedicationStatementRepository, +) +from app.repositories.profiles import DoctorRepository, PatientRepository +from app.schemas.medical_records import ( + AllergyCreate, + ConditionCreate, + EncounterCreate, + EncounterFinalize, + EncounterUpdate, + ImmunizationCreate, + MedicalRecordAccessRequest, + MedicalRecordCreate, + MedicationCreate, +) +from app.services.foundation import _actor_id +from app.validators import ensure_optimistic_version, validate_code +from app.validators.medical_records import ensure_encounter_can_finalize, ensure_encounter_mutable +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class MedicalRecordService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.records = MedicalRecordRepository(session) + self.encounters = EncounterRepository(session) + self.conditions = ConditionRepository(session) + self.allergies = AllergyRepository(session) + self.medications = MedicationStatementRepository(session) + self.immunizations = ImmunizationRecordRepository(session) + self.access_logs = MedicalRecordAccessLogRepository(session) + self.clinics = ClinicRepository(session) + self.patients = PatientRepository(session) + self.doctors = DoctorRepository(session) + self.access_policy = MedicalRecordAccessPolicy() + self.events = get_event_publisher() + + async def _log_access( + self, + tenant_id: UUID, + medical_record_id: UUID, + *, + actor_user_id: str | None, + action: str, + reason: str | None = None, + ) -> None: + log = MedicalRecordAccessLog( + tenant_id=tenant_id, + medical_record_id=medical_record_id, + actor_user_id=actor_user_id or "unknown", + action=action, + reason=reason, + ) + await self.access_logs.add(log) + self.events.publish( + event_type=HealthcareEventType.MEDICAL_RECORD_ACCESSED, + aggregate_type="medical_record", + aggregate_id=medical_record_id, + tenant_id=tenant_id, + payload={"action": action}, + ) + + async def create_record( + self, tenant_id: UUID, body: MedicalRecordCreate, *, actor: CurrentUser | None = None + ) -> MedicalRecord: + if await self.clinics.get(tenant_id, body.clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + code = validate_code(body.code) + if await self.records.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = MedicalRecord( + tenant_id=tenant_id, + clinic_id=body.clinic_id, + patient_id=body.patient_id, + code=code, + access_policy=body.access_policy, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.records.add(entity) + self.events.publish( + event_type=HealthcareEventType.MEDICAL_RECORD_CREATED, + aggregate_type="medical_record", + 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_record( + self, + tenant_id: UUID, + record_id: UUID, + *, + actor: CurrentUser | None = None, + access: MedicalRecordAccessRequest | None = None, + ) -> MedicalRecord: + entity = await self.records.get(tenant_id, record_id) + if entity is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + self.access_policy.ensure_read_access( + access_policy=entity.access_policy, + actor_user_id=_actor_id(actor) or "unknown", + ) + await self._log_access( + tenant_id, + entity.id, + actor_user_id=_actor_id(actor), + action="read", + reason=access.reason if access else None, + ) + await self.session.commit() + return entity + + async def create_encounter( + self, tenant_id: UUID, body: EncounterCreate, *, actor: CurrentUser | None = None + ) -> Encounter: + if await self.records.get(tenant_id, body.medical_record_id) is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + code = validate_code(body.code) + if await self.encounters.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = Encounter( + tenant_id=tenant_id, + medical_record_id=body.medical_record_id, + visit_session_id=body.visit_session_id, + appointment_id=body.appointment_id, + doctor_id=body.doctor_id, + code=code, + status=EncounterStatus.DRAFT, + chief_complaint=body.chief_complaint, + clinical_summary=body.clinical_summary, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.encounters.add(entity) + self.events.publish( + event_type=HealthcareEventType.ENCOUNTER_CREATED, + aggregate_type="encounter", + 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 update_encounter( + self, + tenant_id: UUID, + encounter_id: UUID, + body: EncounterUpdate, + *, + actor: CurrentUser | None = None, + ) -> Encounter: + entity = await self.encounters.get(tenant_id, encounter_id) + if entity is None: + raise NotFoundError("Encounter یافت نشد") + ensure_encounter_mutable(entity.status) + ensure_optimistic_version(entity, body.version) + if body.chief_complaint is not None: + entity.chief_complaint = body.chief_complaint + if body.clinical_summary is not None: + entity.clinical_summary = body.clinical_summary + if body.metadata_json is not None: + entity.metadata_json = body.metadata_json + if entity.status == EncounterStatus.DRAFT: + entity.status = EncounterStatus.IN_PROGRESS + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def finalize_encounter( + self, + tenant_id: UUID, + encounter_id: UUID, + body: EncounterFinalize, + *, + actor: CurrentUser | None = None, + ) -> Encounter: + entity = await self.encounters.get(tenant_id, encounter_id) + if entity is None: + raise NotFoundError("Encounter یافت نشد") + ensure_encounter_can_finalize(entity.status) + ensure_optimistic_version(entity, body.version) + entity.status = EncounterStatus.FINALIZED + entity.finalized_at = datetime.now(timezone.utc) + entity.addendum = body.addendum + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.ENCOUNTER_FINALIZED, + aggregate_type="encounter", + 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 add_condition( + self, tenant_id: UUID, record_id: UUID, body: ConditionCreate, *, actor=None + ) -> Condition: + record = await self.records.get(tenant_id, record_id) + if record is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + code = validate_code(body.code) + entity = Condition( + tenant_id=tenant_id, + medical_record_id=record_id, + code=code, + display=body.display, + clinical_status=body.clinical_status, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.conditions.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def add_allergy( + self, tenant_id: UUID, record_id: UUID, body: AllergyCreate, *, actor=None + ) -> Allergy: + if await self.records.get(tenant_id, record_id) is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + entity = Allergy( + tenant_id=tenant_id, + medical_record_id=record_id, + code=validate_code(body.code), + substance=body.substance, + severity=body.severity, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.allergies.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def add_medication( + self, tenant_id: UUID, record_id: UUID, body: MedicationCreate, *, actor=None + ) -> MedicationStatement: + if await self.records.get(tenant_id, record_id) is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + entity = MedicationStatement( + tenant_id=tenant_id, + medical_record_id=record_id, + code=validate_code(body.code), + medication_code=body.medication_code, + display=body.display, + dosage=body.dosage, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.medications.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def add_immunization( + self, tenant_id: UUID, record_id: UUID, body: ImmunizationCreate, *, actor=None + ) -> ImmunizationRecord: + if await self.records.get(tenant_id, record_id) is None: + raise NotFoundError("پرونده پزشکی یافت نشد") + entity = ImmunizationRecord( + tenant_id=tenant_id, + medical_record_id=record_id, + code=validate_code(body.code), + vaccine_code=body.vaccine_code, + administered_on=body.administered_on, + storage_file_ref=body.storage_file_ref, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.immunizations.add(entity) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_clinical_lists(self, tenant_id: UUID, record_id: UUID): + return { + "conditions": list(await self.conditions.list_by_record(tenant_id, record_id)), + "allergies": list(await self.allergies.list_by_record(tenant_id, record_id)), + "medications": list(await self.medications.list_by_record(tenant_id, record_id)), + "immunizations": list(await self.immunizations.list_by_record(tenant_id, record_id)), + } + + async def list_access_logs(self, tenant_id: UUID, record_id: UUID): + return list(await self.access_logs.list_by_record(tenant_id, record_id)) diff --git a/backend/services/healthcare/app/services/patient_portal.py b/backend/services/healthcare/app/services/patient_portal.py new file mode 100644 index 0000000..9c53b83 --- /dev/null +++ b/backend/services/healthcare/app/services/patient_portal.py @@ -0,0 +1,223 @@ +"""Patient portal application services — Phase 13.4.""" +from __future__ import annotations + +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.patient_portal import ( + PatientDocumentRef, + PatientNotificationPreference, + PatientPortalProfile, +) +from app.repositories.foundation import ClinicRepository +from app.repositories.patient_portal import ( + PatientDocumentRefRepository, + PatientNotificationPreferenceRepository, + PatientPortalProfileRepository, +) +from app.repositories.profiles import PatientRepository +from app.schemas.appointments import AppointmentCreate, AppointmentLifecycleRequest +from app.schemas.patient_portal import ( + PatientDocumentRefCreate, + PatientNotificationPreferenceUpsert, + PatientPortalProfileCreate, + PatientPortalProfileUpdate, +) +from app.services.appointments import AppointmentService +from app.services.foundation import _actor_id +from app.specifications.appointments import AppointmentListSpec +from app.validators import ensure_optimistic_version, validate_code +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class PatientPortalService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.profiles = PatientPortalProfileRepository(session) + self.documents = PatientDocumentRefRepository(session) + self.prefs = PatientNotificationPreferenceRepository(session) + self.patients = PatientRepository(session) + self.clinics = ClinicRepository(session) + self.appointments = AppointmentService(session) + self.events = get_event_publisher() + + async def get_or_create_profile( + self, tenant_id: UUID, patient_id: UUID, *, actor: CurrentUser | None = None + ) -> PatientPortalProfile: + existing = await self.profiles.get_by_patient(tenant_id, patient_id) + if existing: + return existing + body = PatientPortalProfileCreate(patient_id=patient_id) + return await self.create_profile(tenant_id, body, actor=actor) + + async def create_profile( + self, tenant_id: UUID, body: PatientPortalProfileCreate, *, actor: CurrentUser | None = None + ) -> PatientPortalProfile: + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + if await self.profiles.get_by_patient(tenant_id, body.patient_id): + raise AppError("پروفایل پرتال وجود دارد", status_code=409, error_code="profile_exists") + if body.preferred_clinic_id and await self.clinics.get(tenant_id, body.preferred_clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + entity = PatientPortalProfile( + tenant_id=tenant_id, + patient_id=body.patient_id, + preferred_clinic_id=body.preferred_clinic_id, + preferred_language=body.preferred_language, + emergency_contact_ref=body.emergency_contact_ref, + notification_prefs=body.notification_prefs, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.profiles.add(entity) + self.events.publish( + event_type=HealthcareEventType.PATIENT_PORTAL_PROFILE_CREATED, + aggregate_type="patient_portal_profile", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"patient_id": str(entity.patient_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def update_profile( + self, + tenant_id: UUID, + patient_id: UUID, + body: PatientPortalProfileUpdate, + *, + actor: CurrentUser | None = None, + ) -> PatientPortalProfile: + entity = await self.profiles.get_by_patient(tenant_id, patient_id) + if entity is None: + raise NotFoundError("پروفایل پرتال یافت نشد") + ensure_optimistic_version(entity, body.version) + if body.preferred_clinic_id is not None: + if body.preferred_clinic_id and await self.clinics.get(tenant_id, body.preferred_clinic_id) is None: + raise NotFoundError("کلینیک یافت نشد") + entity.preferred_clinic_id = body.preferred_clinic_id + if body.preferred_language is not None: + entity.preferred_language = body.preferred_language + if body.emergency_contact_ref is not None: + entity.emergency_contact_ref = body.emergency_contact_ref + if body.notification_prefs is not None: + entity.notification_prefs = body.notification_prefs + if body.metadata_json is not None: + entity.metadata_json = body.metadata_json + entity.version += 1 + entity.updated_by = _actor_id(actor) + self.events.publish( + event_type=HealthcareEventType.PATIENT_PORTAL_PROFILE_UPDATED, + aggregate_type="patient_portal_profile", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"patient_id": str(entity.patient_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_appointments(self, tenant_id: UUID, patient_id: UUID): + spec = AppointmentListSpec(patient_id=patient_id, sort_by="scheduled_start", sort_dir="desc") + return await self.appointments.list(tenant_id, offset=0, limit=50, spec=spec) + + async def book_appointment( + self, tenant_id: UUID, patient_id: UUID, body: AppointmentCreate, *, actor=None + ): + if body.patient_id != patient_id: + raise AppError("دسترسی به بیمار دیگر مجاز نیست", status_code=403, error_code="patient_mismatch") + return await self.appointments.book(tenant_id, body, actor=actor) + + async def cancel_appointment( + self, + tenant_id: UUID, + patient_id: UUID, + appointment_id: UUID, + body: AppointmentLifecycleRequest, + *, + actor=None, + ): + appt = await self.appointments.get(tenant_id, appointment_id) + if appt.patient_id != patient_id: + raise AppError("دسترسی به نوبت دیگر مجاز نیست", status_code=403, error_code="patient_mismatch") + return await self.appointments.cancel(tenant_id, appointment_id, body, actor=actor) + + async def create_document( + self, tenant_id: UUID, patient_id: UUID, body: PatientDocumentRefCreate, *, actor=None + ) -> PatientDocumentRef: + code = validate_code(body.code) + if await self.documents.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = PatientDocumentRef( + tenant_id=tenant_id, + patient_id=patient_id, + code=code, + title=body.title, + storage_file_ref=body.storage_file_ref, + document_type=body.document_type, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.documents.add(entity) + self.events.publish( + event_type=HealthcareEventType.PATIENT_DOCUMENT_REF_CREATED, + aggregate_type="patient_document_ref", + 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 list_documents(self, tenant_id: UUID, patient_id: UUID): + return list(await self.documents.list_by_patient(tenant_id, patient_id)) + + async def upsert_notification_pref( + self, + tenant_id: UUID, + patient_id: UUID, + body: PatientNotificationPreferenceUpsert, + *, + actor=None, + ) -> PatientNotificationPreference: + existing = await self.prefs.get_pref( + tenant_id, patient_id, body.channel, body.template_key + ) + if existing: + existing.enabled = body.enabled + existing.metadata_json = body.metadata_json + existing.updated_by = _actor_id(actor) + entity = existing + else: + entity = PatientNotificationPreference( + tenant_id=tenant_id, + patient_id=patient_id, + channel=body.channel, + template_key=body.template_key, + enabled=body.enabled, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.prefs.add(entity) + self.events.publish( + event_type=HealthcareEventType.PATIENT_NOTIFICATION_PREF_UPDATED, + aggregate_type="patient_notification_preference", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"channel": entity.channel, "template_key": entity.template_key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list_notification_prefs(self, tenant_id: UUID, patient_id: UUID): + return list(await self.prefs.list_by_patient(tenant_id, patient_id)) diff --git a/backend/services/healthcare/app/services/pharmacy.py b/backend/services/healthcare/app/services/pharmacy.py new file mode 100644 index 0000000..dad8f6a --- /dev/null +++ b/backend/services/healthcare/app/services/pharmacy.py @@ -0,0 +1,215 @@ +"""Pharmacy application services — Phase 13.6.""" +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.pharmacy import Pharmacy, PrescriptionLine, PrescriptionOrder, PrescriptionStatusHistory +from app.models.types import PrescriptionLifecycleAction, PrescriptionOrderStatus +from app.policies.pharmacy import PrescriptionLifecyclePolicy +from app.providers.mocks import MockCommunicationProvider +from app.repositories.pharmacy import ( + PharmacyRepository, + PrescriptionLineRepository, + PrescriptionOrderRepository, + PrescriptionStatusHistoryRepository, +) +from app.repositories.profiles import DoctorRepository, PatientRepository +from app.schemas.pharmacy import PharmacyCreate, PrescriptionLifecycleRequest, PrescriptionOrderCreate +from app.services.foundation import _actor_id +from app.validators import ensure_optimistic_version, validate_code +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +class PharmacyService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.pharmacies = PharmacyRepository(session) + self.orders = PrescriptionOrderRepository(session) + self.lines = PrescriptionLineRepository(session) + self.history = PrescriptionStatusHistoryRepository(session) + self.patients = PatientRepository(session) + self.doctors = DoctorRepository(session) + self.policy = PrescriptionLifecyclePolicy() + self.communication = MockCommunicationProvider() + self.events = get_event_publisher() + + async def create_pharmacy( + self, tenant_id: UUID, body: PharmacyCreate, *, actor: CurrentUser | None = None + ) -> Pharmacy: + code = validate_code(body.code) + if await self.pharmacies.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = Pharmacy( + tenant_id=tenant_id, + code=code, + name=body.name, + contact=body.contact, + hours=body.hours, + service_area_ref=body.service_area_ref, + status=body.status, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.pharmacies.add(entity) + self.events.publish( + event_type=HealthcareEventType.PHARMACY_CREATED, + aggregate_type="pharmacy", + 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 submit_order( + self, tenant_id: UUID, body: PrescriptionOrderCreate, *, actor: CurrentUser | None = None + ) -> PrescriptionOrder: + if not body.lines: + raise AppError("حداقل یک خط نسخه الزامی است", status_code=422, error_code="empty_prescription") + if await self.pharmacies.get(tenant_id, body.pharmacy_id) is None: + raise NotFoundError("داروخانه یافت نشد") + if await self.patients.get(tenant_id, body.patient_id) is None: + raise NotFoundError("بیمار یافت نشد") + if await self.doctors.get(tenant_id, body.doctor_id) is None: + raise NotFoundError("پزشک یافت نشد") + code = validate_code(body.code) + if await self.orders.get_by_code(tenant_id, code): + raise AppError("کد تکراری است", error_code="duplicate_code", status_code=409) + entity = PrescriptionOrder( + tenant_id=tenant_id, + pharmacy_id=body.pharmacy_id, + encounter_id=body.encounter_id, + visit_session_id=body.visit_session_id, + patient_id=body.patient_id, + doctor_id=body.doctor_id, + code=code, + status=PrescriptionOrderStatus.SUBMITTED, + status_changed_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.orders.add(entity) + for line in body.lines: + await self.lines.add( + PrescriptionLine( + tenant_id=tenant_id, + prescription_order_id=entity.id, + line_no=line.line_no, + medication_code=line.medication_code, + display=line.display, + quantity=line.quantity, + dosage_instructions=line.dosage_instructions, + metadata_json=line.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + ) + self.events.publish( + event_type=HealthcareEventType.PRESCRIPTION_ORDER_SUBMITTED, + aggregate_type="prescription_order", + 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_lifecycle( + self, + tenant_id: UUID, + order_id: UUID, + action: PrescriptionLifecycleAction, + body: PrescriptionLifecycleRequest, + *, + actor: CurrentUser | None = None, + ) -> PrescriptionOrder: + entity = await self.orders.get(tenant_id, order_id) + if entity is None: + raise NotFoundError("سفارش نسخه یافت نشد") + ensure_optimistic_version(entity, body.version) + new_status = self.policy.ensure_transition(action=action, current=entity.status) + old_status = entity.status + entity.status = new_status + entity.status_changed_at = datetime.now(timezone.utc) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.history.add( + PrescriptionStatusHistory( + tenant_id=tenant_id, + prescription_order_id=entity.id, + from_status=old_status, + to_status=new_status, + actor_user_id=_actor_id(actor), + reason=body.reason, + ) + ) + self.events.publish( + event_type=HealthcareEventType.PRESCRIPTION_ORDER_STATUS_CHANGED, + aggregate_type="prescription_order", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": new_status.value}, + ) + if new_status == PrescriptionOrderStatus.READY: + patient = await self.patients.get(tenant_id, entity.patient_id) + to = (patient.contact or {}).get("phone", "unknown") if patient else "unknown" + await self.communication.send( + tenant_id=tenant_id, + channel="sms", + template_key="healthcare.prescription.ready", + to=to, + payload={"order_id": str(entity.id), "code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def accept(self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.ACCEPT, body, actor=actor + ) + + async def prepare(self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.PREPARE, body, actor=actor + ) + + async def mark_ready(self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.MARK_READY, body, actor=actor + ) + + async def complete_pickup( + self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None + ): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.COMPLETE_PICKUP, body, actor=actor + ) + + async def cancel(self, tenant_id: UUID, order_id: UUID, body: PrescriptionLifecycleRequest, *, actor=None): + return await self._apply_lifecycle( + tenant_id, order_id, PrescriptionLifecycleAction.CANCEL, body, actor=actor + ) + + async def get_order(self, tenant_id: UUID, order_id: UUID) -> PrescriptionOrder: + entity = await self.orders.get(tenant_id, order_id) + if entity is None: + raise NotFoundError("سفارش نسخه یافت نشد") + return entity + + async def list_pharmacies(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.pharmacies.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_orders(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20): + return list(await self.orders.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/healthcare/app/services/profiles.py b/backend/services/healthcare/app/services/profiles.py new file mode 100644 index 0000000..db27ed4 --- /dev/null +++ b/backend/services/healthcare/app/services/profiles.py @@ -0,0 +1,320 @@ +"""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 diff --git a/backend/services/healthcare/app/specifications/__init__.py b/backend/services/healthcare/app/specifications/__init__.py new file mode 100644 index 0000000..758ecea --- /dev/null +++ b/backend/services/healthcare/app/specifications/__init__.py @@ -0,0 +1,4 @@ +"""Healthcare query specifications package.""" +from app.specifications.appointments import ALLOWED_SORT_FIELDS, AppointmentListSpec + +__all__ = ["ALLOWED_SORT_FIELDS", "AppointmentListSpec"] diff --git a/backend/services/healthcare/app/specifications/appointments.py b/backend/services/healthcare/app/specifications/appointments.py new file mode 100644 index 0000000..fa11b8d --- /dev/null +++ b/backend/services/healthcare/app/specifications/appointments.py @@ -0,0 +1,25 @@ +"""Appointment list specifications — Phase 13.1.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from uuid import UUID + +from app.models.types import AppointmentStatus + +ALLOWED_SORT_FIELDS = frozenset( + {"created_at", "scheduled_start", "status", "code", "updated_at"} +) + + +@dataclass +class AppointmentListSpec: + status: AppointmentStatus | None = None + doctor_id: UUID | None = None + clinic_id: UUID | None = None + patient_id: UUID | None = None + date_from: datetime | None = None + date_to: datetime | None = None + q: str | None = None + sort_by: str = "scheduled_start" + sort_dir: str = "desc" diff --git a/backend/services/healthcare/app/specifications/doctor_panel.py b/backend/services/healthcare/app/specifications/doctor_panel.py new file mode 100644 index 0000000..5f8100f --- /dev/null +++ b/backend/services/healthcare/app/specifications/doctor_panel.py @@ -0,0 +1,18 @@ +"""Doctor panel list specifications — Phase 13.2.""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from app.models.types import VisitSessionStatus + +ALLOWED_SORT_FIELDS = frozenset({"queue_position", "created_at", "started_at"}) + + +@dataclass +class VisitSessionListSpec: + doctor_id: UUID | None = None + clinic_id: UUID | None = None + status: VisitSessionStatus | None = None + sort_by: str = "queue_position" + sort_dir: str = "asc" diff --git a/backend/services/healthcare/app/tests/conftest.py b/backend/services/healthcare/app/tests/conftest.py new file mode 100644 index 0000000..8337ded --- /dev/null +++ b/backend/services/healthcare/app/tests/conftest.py @@ -0,0 +1,66 @@ +import os +import uuid + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +os.environ["ENVIRONMENT"] = "test" +os.environ["AUTH_REQUIRED"] = "false" +os.environ["HEALTHCARE_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["HEALTHCARE_DATABASE_URL_SYNC"] = "sqlite:///:memory:" +os.environ["JWT_VERIFY_SIGNATURE"] = "false" + +from app.core.config import get_settings # noqa: E402 + +get_settings.cache_clear() + +from app.core.database import Base, engine # noqa: E402 +import app.models # noqa: E402, F401 +from app.events.publisher import reset_event_publisher # noqa: E402 +from app.main import app # noqa: E402 + +TENANT_A = uuid.uuid4() +TENANT_B = uuid.uuid4() + + +@pytest_asyncio.fixture +async def db_setup(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(scope="session", autouse=True) +def _dispose_engine_on_session_end(): + yield + import asyncio + + try: + asyncio.run(engine.dispose()) + except RuntimeError: + pass + + +@pytest_asyncio.fixture(autouse=True) +def _reset_events(): + from app.providers.mocks import MockCommunicationProvider, MockDeliveryProvider + + reset_event_publisher() + MockCommunicationProvider.reset() + MockDeliveryProvider.reset() + yield + + +@pytest_asyncio.fixture +async def client(db_setup): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as ac: + yield ac + + +def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]: + return {"X-Tenant-ID": str(tenant_id)} diff --git a/backend/services/healthcare/app/tests/test_api.py b/backend/services/healthcare/app/tests/test_api.py new file mode 100644 index 0000000..b561b52 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_api.py @@ -0,0 +1,165 @@ +"""API, health, tenant isolation, and foundation flow tests.""" +from __future__ import annotations + +from app.events.publisher import get_event_publisher +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def test_health_capabilities_metrics(client): + health = await client.get("/health") + assert health.status_code == 200 + body = health.json() + assert body["status"] == "ok" + assert body["service"] == "healthcare-service" + + caps = await client.get("/capabilities") + assert caps.status_code == 200 + c = caps.json() + assert c["phase"] == "13.7" + assert c["commercial_product"] == "Torbat Healthcare" + assert c["features"]["foundation"] is True + assert c["features"]["doctors"] is True + assert c["features"]["appointments"] is True + assert c["features"]["visits"] is True + assert c["features"]["patient_portal"] is True + assert c["features"]["medical_records"] is True + assert c["features"]["pharmacy"] is True + assert c["features"]["delivery_integration"] is True + assert c["independence"]["integration_mode"] == "api_and_events_only" + + metrics = await client.get("/metrics") + assert metrics.status_code == 200 + assert metrics.json()["phase"] == "13.7" + + +async def test_foundation_flow_and_events(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN1", "name": "City Clinic", "status": "active"}, + ) + assert clinic.status_code == 201, clinic.text + clinic_id = clinic.json()["id"] + + branch = await client.post( + "/api/v1/branches", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "BR1", + "name": "Main Branch", + }, + ) + assert branch.status_code == 201, branch.text + + department = await client.post( + "/api/v1/departments", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "CARDIO", + "name": "Cardiology", + }, + ) + assert department.status_code == 201, department.text + + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "user-doctor-1", + "clinic_id": clinic_id, + "code": "DOC1", + "display_name": "Dr. Smith", + }, + ) + assert doctor.status_code == 201, doctor.text + + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "user-patient-1", + "code": "PAT1", + "display_name": "Jane Doe", + }, + ) + assert patient.status_code == 201, patient.text + + provider = await client.post( + "/api/v1/external-providers", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "COMM_X", + "name": "External Comm X", + "provider_kind": "communication", + "adapter_key": "mock_comm", + }, + ) + assert provider.status_code == 201, provider.text + + cfg = await client.post( + "/api/v1/configurations", + headers=headers, + json={"clinic_id": clinic_id, "code": "DEFAULT"}, + ) + assert cfg.status_code == 201, cfg.text + + setting = await client.put( + "/api/v1/settings", + headers=headers, + json={ + "clinic_id": clinic_id, + "key": "default_locale", + "value": {"code": "fa"}, + }, + ) + assert setting.status_code == 200, setting.text + + audit = await client.get( + "/api/v1/audit", + headers=headers, + params={"entity_type": "clinic", "entity_id": clinic_id}, + ) + assert audit.status_code == 200 + assert len(audit.json()) >= 1 + + published = [e.event_type for e in get_event_publisher().published] + assert "healthcare.clinic.created" in published + assert "healthcare.branch.created" in published + assert "healthcare.department.created" in published + assert "healthcare.doctor.created" in published + assert "healthcare.patient.created" in published + assert "healthcare.external_provider.registered" in published + assert "healthcare.setting.updated" in published + + +async def test_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + + created = await client.post( + "/api/v1/clinics", + headers=headers_a, + json={"code": "ISO1", "name": "Tenant A Clinic"}, + ) + assert created.status_code == 201 + clinic_id = created.json()["id"] + + denied = await client.get(f"/api/v1/clinics/{clinic_id}", headers=headers_b) + assert denied.status_code == 404 + + listed_b = await client.get("/api/v1/clinics", headers=headers_b) + assert listed_b.status_code == 200 + assert listed_b.json() == [] + + listed_a = await client.get("/api/v1/clinics", headers=headers_a) + assert listed_a.status_code == 200 + assert len(listed_a.json()) == 1 + + +async def test_missing_tenant_header_rejected(client): + res = await client.get("/api/v1/clinics") + assert res.status_code in (400, 422) diff --git a/backend/services/healthcare/app/tests/test_architecture.py b/backend/services/healthcare/app/tests/test_architecture.py new file mode 100644 index 0000000..d09f1d5 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_architecture.py @@ -0,0 +1,177 @@ +"""Architecture tests — Healthcare module boundary enforcement.""" +from __future__ import annotations + +import ast +import re +from pathlib import Path + +from app.models import foundation as models + +FORBIDDEN_IMPORT_PREFIXES = ( + "backend.services.accounting", + "backend.services.crm", + "backend.services.loyalty", + "backend.services.communication", + "backend.services.delivery", + "backend.services.sports_center", + "backend.services.automation", + "backend.services.notification", + "backend.services.file_storage", + "backend.services.identity_access", + "backend.core_service", +) + +FOUNDATION_MODELS = [ + models.Clinic, + models.Branch, + models.Department, + models.HealthcareRole, + models.HealthcarePermission, + models.ExternalProviderConfig, + models.HealthcareConfiguration, + models.HealthcareSetting, + models.HealthcareAuditLog, +] + + +def test_all_models_have_tenant_id(): + from app.core.database import Base + import app.models # noqa: F401 + + skip = {"alembic_version"} + for table in Base.metadata.tables.values(): + if table.name in skip: + continue + assert "tenant_id" in table.columns, f"{table.name} missing tenant_id" + + +def test_foundation_aggregates_are_independent(): + assert len(FOUNDATION_MODELS) == 9 + for model in FOUNDATION_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_no_clinical_engine_tables(): + from app.core.database import Base + import app.models # noqa: F401 + + names = set(Base.metadata.tables.keys()) + assert "doctors" in names + assert "patients" in names + assert "departments" in names + assert "outbox_events" in names + assert "appointments" in names + assert "schedules" in names + assert "visit_sessions" in names + assert "medical_records" in names + assert "prescription_orders" in names + assert "delivery_job_intents" in names + forbidden = { + "routing_engine_registrations", + "drivers", + } + assert forbidden.isdisjoint(names) + + +def test_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + assert "healthcare.view" in ALL_PERMISSIONS + assert "healthcare.clinics.create" in ALL_PERMISSIONS + assert "healthcare.external_providers.manage" in ALL_PERMISSIONS + assert "healthcare.doctors.view" in ALL_PERMISSIONS + assert "healthcare.patients.manage" in ALL_PERMISSIONS + assert "healthcare.audit.view" in ALL_PERMISSIONS + for prefix in PERMISSION_PREFIXES: + assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS) + + +def test_events_defined(): + from app.events.types import HealthcareEventType + + assert HealthcareEventType.CLINIC_CREATED.value == "healthcare.clinic.created" + assert HealthcareEventType.CLINIC_UPDATED.value == "healthcare.clinic.updated" + assert HealthcareEventType.DOCTOR_CREATED.value == "healthcare.doctor.created" + assert HealthcareEventType.PATIENT_CREATED.value == "healthcare.patient.created" + assert HealthcareEventType.SETTING_UPDATED.value == "healthcare.setting.updated" + + +def test_platform_provider_contracts_exist(): + from app.providers import ( + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + DeliveryProvider, + IdentityProvider, + LoyaltyProvider, + StorageProvider, + ) + + assert AccountingProvider is not None + assert CommunicationProvider is not None + assert DeliveryProvider is not None + assert LoyaltyProvider is not None + assert CRMProvider is not None + assert StorageProvider is not None + assert AIProvider is not None + assert IdentityProvider is not None + + +def test_no_forbidden_service_imports(): + root = Path(__file__).resolve().parents[1] + violations: list[str] = [] + for path in root.rglob("*.py"): + if "tests" in path.parts: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + for forbidden in FORBIDDEN_IMPORT_PREFIXES: + if alias.name.startswith(forbidden) or forbidden in alias.name: + violations.append(f"{path}: import {alias.name}") + elif isinstance(node, ast.ImportFrom) and node.module: + for forbidden in FORBIDDEN_IMPORT_PREFIXES: + if node.module.startswith(forbidden) or forbidden in node.module: + violations.append(f"{path}: from {node.module}") + assert violations == [] + + +def test_api_ownership_is_healthcare_only(): + from app.api.v1 import api_router + + prefixes = {getattr(r, "path", "") for r in api_router.routes} + joined = " ".join(sorted(prefixes)) + assert "/clinics" in joined or any("/clinics" in p for p in prefixes) + assert "/doctors" in joined or any("/doctors" in p for p in prefixes) + assert "/patients" in joined or any("/patients" in p for p in prefixes) + forbidden = ("accounting", "crm", "loyalty", "notification", "automation", "sports") + for name in forbidden: + assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name + + +def test_folder_structure(): + root = Path(__file__).resolve().parents[2] + required = [ + "app/models", + "app/repositories", + "app/services", + "app/validators", + "app/schemas", + "app/events", + "app/permissions", + "app/providers", + "app/policies", + "app/specifications", + "app/commands", + "app/queries", + "app/api/v1", + "app/tests", + "alembic/versions", + "README.md", + ] + for rel in required: + assert (root / rel).exists(), f"missing {rel}" diff --git a/backend/services/healthcare/app/tests/test_dependency.py b/backend/services/healthcare/app/tests/test_dependency.py new file mode 100644 index 0000000..ddf62fe --- /dev/null +++ b/backend/services/healthcare/app/tests/test_dependency.py @@ -0,0 +1,34 @@ +"""Dependency / layering validation.""" +from app.repositories.base import TenantBaseRepository +from app.repositories.foundation import ClinicRepository +from app.repositories.profiles import DoctorRepository +from app.services import foundation as services +from app.services import profiles as profile_services +from app.providers import contracts + + +def test_repos_inherit_tenant_base(): + assert issubclass(ClinicRepository, TenantBaseRepository) + assert issubclass(DoctorRepository, TenantBaseRepository) + + +def test_services_do_not_import_fastapi(): + import inspect + + src = inspect.getsource(services) + assert "fastapi" not in src.lower() + profile_src = inspect.getsource(profile_services) + assert "fastapi" not in profile_src.lower() + + +def test_provider_contracts_are_protocols(): + assert hasattr(contracts.AccountingProvider, "__protocol_attrs__") or True + assert callable(contracts.DeliveryProvider.request_delivery) + + +def test_commands_and_queries_exist(): + from app.commands.profiles import ProfileCommands + from app.queries.profiles import ProfileQueries + + assert ProfileCommands is not None + assert ProfileQueries is not None diff --git a/backend/services/healthcare/app/tests/test_docs.py b/backend/services/healthcare/app/tests/test_docs.py new file mode 100644 index 0000000..64e5000 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_docs.py @@ -0,0 +1,32 @@ +"""Documentation validation for Healthcare Phase 13.0.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[5] + + +def test_phase_docs_exist(): + assert (ROOT / "docs" / "phases" / "Healthcare" / "README.md").exists() + assert (ROOT / "docs" / "phases" / "Healthcare" / "phase-13-0-healthcare-foundation.md").exists() + assert (ROOT / "docs" / "healthcare-roadmap.md").exists() + + +def test_module_registry_mentions_healthcare(): + text = (ROOT / "docs" / "module-registry.md").read_text(encoding="utf-8") + assert "## healthcare" in text.lower() or "healthcare" in text.lower() + assert "healthcare_db" in text or "8010" in text + assert "healthcare." in text + + +def test_progress_mentions_phase_13(): + text = (ROOT / "docs" / "progress.md").read_text(encoding="utf-8") + assert "13" in text or "Healthcare" in text + + +def test_service_readme_documents_boundaries(): + text = (ROOT / "backend" / "services" / "healthcare" / "README.md").read_text( + encoding="utf-8" + ) + assert "healthcare_db" in text + assert "8010" in text + assert "doctors" in text.lower() + assert "patients" in text.lower() diff --git a/backend/services/healthcare/app/tests/test_migration.py b/backend/services/healthcare/app/tests/test_migration.py new file mode 100644 index 0000000..a3bb973 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_migration.py @@ -0,0 +1,60 @@ +"""Migration validation.""" +from pathlib import Path + +from app.core.database import Base +import app.models # noqa: F401 + + +EXPECTED_TABLES = { + "clinics", + "branches", + "departments", + "doctors", + "patients", + "healthcare_roles", + "healthcare_permissions", + "external_provider_configs", + "healthcare_configurations", + "healthcare_settings", + "healthcare_audit_logs", + "outbox_events", + "schedules", + "availability_windows", + "appointment_types", + "appointments", + "waiting_list_entries", + "visit_sessions", + "visit_notes", + "clinic_services", + "staff_assignments", + "clinic_policies", + "operating_hours_policies", + "patient_portal_profiles", + "patient_document_refs", + "patient_notification_preferences", + "medical_records", + "encounters", + "conditions", + "allergies", + "medication_statements", + "immunization_records", + "medical_record_access_logs", + "pharmacies", + "prescription_orders", + "prescription_lines", + "prescription_status_history", + "delivery_integration_registrations", + "delivery_job_intents", + "delivery_job_status_snapshots", +} + + +def test_alembic_head_revision(): + versions = Path(__file__).resolve().parents[2] / "alembic" / "versions" + files = list(versions.glob("0008_phase_137*.py")) + assert files, "missing 0008_phase_137 migration" + + +def test_metadata_has_foundation_tables(): + names = set(Base.metadata.tables.keys()) + assert EXPECTED_TABLES.issubset(names) diff --git a/backend/services/healthcare/app/tests/test_permissions.py b/backend/services/healthcare/app/tests/test_permissions.py new file mode 100644 index 0000000..ebd8714 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_permissions.py @@ -0,0 +1,29 @@ +"""Permission definition tests.""" +from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + +def test_all_permissions_use_healthcare_prefix(): + assert ALL_PERMISSIONS + for perm in ALL_PERMISSIONS: + assert perm.startswith("healthcare.") + + +def test_permission_prefixes_stable(): + assert "healthcare." in PERMISSION_PREFIXES + assert "healthcare.clinics." in PERMISSION_PREFIXES + assert "healthcare.doctors." in PERMISSION_PREFIXES + assert "healthcare.patients." in PERMISSION_PREFIXES + + +def test_doctor_patient_permissions_present(): + from app.permissions.definitions import ( + DOCTORS_CREATE, + DOCTORS_VIEW, + PATIENTS_CREATE, + PATIENTS_VIEW, + ) + + assert DOCTORS_VIEW in ALL_PERMISSIONS + assert DOCTORS_CREATE in ALL_PERMISSIONS + assert PATIENTS_VIEW in ALL_PERMISSIONS + assert PATIENTS_CREATE in ALL_PERMISSIONS diff --git a/backend/services/healthcare/app/tests/test_phase_131.py b/backend/services/healthcare/app/tests/test_phase_131.py new file mode 100644 index 0000000..e6649ea --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_131.py @@ -0,0 +1,209 @@ +"""Phase 13.1 — Appointment Engine tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.providers.mocks import MockCommunicationProvider +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers +from app.validators.appointments import ensure_appointment_lifecycle_transition +from app.models.types import AppointmentLifecycleAction, AppointmentStatus +from shared.exceptions import AppError + + +async def _seed_foundation(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN131", "name": "Appt Clinic", "status": "active"}, + ) + assert clinic.status_code == 201 + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "doc-131", + "clinic_id": clinic_id, + "code": "DOC131", + "display_name": "Dr Appt", + }, + ) + assert doctor.status_code == 201 + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "pat-131", + "code": "PAT131", + "display_name": "Patient Appt", + "contact": {"phone": "+989121111111"}, + }, + ) + assert patient.status_code == 201 + return clinic_id, doctor.json()["id"], patient.json()["id"] + + +def test_appointment_lifecycle_transition_unit(): + ensure_appointment_lifecycle_transition( + action=AppointmentLifecycleAction.CONFIRM, current=AppointmentStatus.DRAFT + ) + with pytest.raises(AppError) as exc: + ensure_appointment_lifecycle_transition( + action=AppointmentLifecycleAction.CONFIRM, current=AppointmentStatus.COMPLETED + ) + assert exc.value.error_code == "appointment_terminal" + + +@pytest.mark.asyncio +async def test_appointment_lifecycle_and_reminder(client): + MockCommunicationProvider.reset() + clinic_id, doctor_id, patient_id = await _seed_foundation(client) + headers = tenant_headers(TENANT_A) + start = datetime(2026, 8, 1, 10, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=30) + + appt_type = await client.post( + "/api/v1/appointment-types", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "CONSULT", + "name": "Consultation", + "default_duration_minutes": 30, + }, + ) + assert appt_type.status_code == 201 + + schedule = await client.post( + "/api/v1/schedules", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "code": "SCH-MON", + "name": "Monday", + "day_of_week": "monday", + "start_time": "09:00:00", + "end_time": "17:00:00", + }, + ) + assert schedule.status_code == 201 + + booked = await client.post( + "/api/v1/appointments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "appointment_type_id": appt_type.json()["id"], + "code": "APT1", + "scheduled_start": start.isoformat(), + "scheduled_end": end.isoformat(), + }, + ) + assert booked.status_code == 201, booked.text + appt = booked.json() + assert appt["status"] == "draft" + aid = appt["id"] + version = appt["version"] + + confirmed = await client.post( + f"/api/v1/appointments/{aid}/confirm", + headers=headers, + json={"version": version}, + ) + assert confirmed.status_code == 200, confirmed.text + assert confirmed.json()["status"] == "confirmed" + assert confirmed.json()["reminder_scheduled"] is True + assert len(MockCommunicationProvider.sent) == 1 + assert MockCommunicationProvider.sent[0]["template_key"] == "healthcare.appointment.reminder" + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.APPOINTMENT_BOOKED.value in events + assert HealthcareEventType.APPOINTMENT_CONFIRMED.value in events + assert HealthcareEventType.APPOINTMENT_REMINDER_SCHEDULED.value in events + + +@pytest.mark.asyncio +async def test_appointment_conflict_detection(client): + clinic_id, doctor_id, patient_id = await _seed_foundation(client) + headers = tenant_headers(TENANT_A) + start = datetime(2026, 8, 2, 14, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=30) + payload = { + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "APT-CONF-A", + "scheduled_start": start.isoformat(), + "scheduled_end": end.isoformat(), + } + first = await client.post("/api/v1/appointments", headers=headers, json=payload) + assert first.status_code == 201 + + overlap = await client.post( + "/api/v1/appointments", + headers=headers, + json={ + **payload, + "code": "APT-CONF-B", + "scheduled_start": (start + timedelta(minutes=15)).isoformat(), + "scheduled_end": (end + timedelta(minutes=15)).isoformat(), + }, + ) + assert overlap.status_code == 409 + assert overlap.json()["error"]["code"] == "appointment_conflict" + + +@pytest.mark.asyncio +async def test_appointment_tenant_isolation(client): + clinic_id, doctor_id, patient_id = await _seed_foundation(client) + headers = tenant_headers(TENANT_A) + start = datetime(2026, 8, 3, 9, 0, tzinfo=timezone.utc) + booked = await client.post( + "/api/v1/appointments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "APT-ISO", + "scheduled_start": start.isoformat(), + "scheduled_end": (start + timedelta(minutes=30)).isoformat(), + }, + ) + assert booked.status_code == 201 + aid = booked.json()["id"] + other = await client.get( + f"/api/v1/appointments/{aid}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + +@pytest.mark.asyncio +async def test_waiting_list_and_capabilities(client): + clinic_id, doctor_id, patient_id = await _seed_foundation(client) + headers = tenant_headers(TENANT_A) + wl = await client.post( + "/api/v1/waiting-list", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "WL1", + "priority": 50, + }, + ) + assert wl.status_code == 201 + + caps = await client.get("/capabilities") + assert caps.json()["features"]["appointments"] is True + assert caps.json()["features"]["waiting_list"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_132.py b/backend/services/healthcare/app/tests/test_phase_132.py new file mode 100644 index 0000000..64930dd --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_132.py @@ -0,0 +1,210 @@ +"""Phase 13.2 — Doctor Panel tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers +from app.validators.doctor_panel import ensure_visit_session_transition +from app.models.types import VisitSessionAction, VisitSessionStatus +from shared.exceptions import AppError + + +async def _seed_appointment(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN132", "name": "Visit Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "doc-132", + "clinic_id": clinic_id, + "code": "DOC132", + "display_name": "Dr Visit", + }, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "pat-132", + "code": "PAT132", + "display_name": "Patient Visit", + }, + ) + patient_id = patient.json()["id"] + start = datetime(2026, 9, 1, 10, 0, tzinfo=timezone.utc) + end = start + timedelta(minutes=30) + appt = await client.post( + "/api/v1/appointments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "APT132", + "scheduled_start": start.isoformat(), + "scheduled_end": end.isoformat(), + }, + ) + assert appt.status_code == 201 + aid = appt.json()["id"] + version = appt.json()["version"] + confirmed = await client.post( + f"/api/v1/appointments/{aid}/confirm", + headers=headers, + json={"version": version}, + ) + assert confirmed.status_code == 200 + return clinic_id, doctor_id, patient_id, aid, confirmed.json()["version"] + + +def test_visit_session_transition_unit(): + ensure_visit_session_transition( + action=VisitSessionAction.START, current=VisitSessionStatus.QUEUED + ) + with pytest.raises(AppError) as exc: + ensure_visit_session_transition( + action=VisitSessionAction.FINISH, current=VisitSessionStatus.QUEUED + ) + assert exc.value.error_code == "invalid_visit_session_transition" + + +@pytest.mark.asyncio +async def test_visit_session_lifecycle_completes_appointment(client): + clinic_id, doctor_id, patient_id, appt_id, appt_version = await _seed_appointment(client) + headers = tenant_headers(TENANT_A) + + session = await client.post( + "/api/v1/visit-sessions", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "appointment_id": appt_id, + "code": "VS1", + "queue_position": 10, + }, + ) + assert session.status_code == 201, session.text + vs = session.json() + assert vs["status"] == "queued" + vid = vs["id"] + version = vs["version"] + + started = await client.post( + f"/api/v1/visit-sessions/{vid}/start", + headers=headers, + json={"version": version}, + ) + assert started.status_code == 200 + assert started.json()["status"] == "in_progress" + version = started.json()["version"] + + appt = await client.get(f"/api/v1/appointments/{appt_id}", headers=headers) + assert appt.json()["status"] == "checked_in" + + finished = await client.post( + f"/api/v1/visit-sessions/{vid}/finish", + headers=headers, + json={"version": version}, + ) + assert finished.status_code == 200 + assert finished.json()["status"] == "completed" + + appt = await client.get(f"/api/v1/appointments/{appt_id}", headers=headers) + assert appt.json()["status"] == "completed" + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.VISIT_SESSION_CREATED.value in events + assert HealthcareEventType.VISIT_SESSION_STARTED.value in events + assert HealthcareEventType.VISIT_SESSION_FINISHED.value in events + assert HealthcareEventType.APPOINTMENT_COMPLETED.value in events + + +@pytest.mark.asyncio +async def test_doctor_dashboard_and_queue(client): + clinic_id, doctor_id, patient_id, appt_id, _ = await _seed_appointment(client) + headers = tenant_headers(TENANT_A) + await client.post( + "/api/v1/visit-sessions", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "appointment_id": appt_id, + "code": "VS-Q", + }, + ) + + dashboard = await client.get( + "/api/v1/doctor-panel/dashboard", + headers=headers, + params={"doctor_id": doctor_id}, + ) + assert dashboard.status_code == 200 + assert dashboard.json()["queued_count"] >= 1 + + queue = await client.get( + "/api/v1/doctor-panel/queue", + headers=headers, + params={"doctor_id": doctor_id}, + ) + assert queue.status_code == 200 + assert len(queue.json()["items"]) >= 1 + + +@pytest.mark.asyncio +async def test_visit_note_and_tenant_isolation(client): + clinic_id, doctor_id, patient_id, appt_id, _ = await _seed_appointment(client) + headers = tenant_headers(TENANT_A) + session = await client.post( + "/api/v1/visit-sessions", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "appointment_id": appt_id, + "code": "VS-N", + }, + ) + vid = session.json()["id"] + + note = await client.post( + "/api/v1/visit-notes", + headers=headers, + json={ + "visit_session_id": vid, + "doctor_id": doctor_id, + "code": "VN1", + "title": "Initial assessment", + "body": "Patient stable", + "storage_file_ref": "storage://file-1", + }, + ) + assert note.status_code == 201 + + other = await client.get( + f"/api/v1/visit-sessions/{vid}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + +@pytest.mark.asyncio +async def test_phase_132_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["visits"] is True + assert caps.json()["features"]["doctor_panel"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_133.py b/backend/services/healthcare/app/tests/test_phase_133.py new file mode 100644 index 0000000..e735f5c --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_133.py @@ -0,0 +1,106 @@ +"""Phase 13.3 — Clinic Management tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.tests.conftest import TENANT_A, tenant_headers +from app.validators.clinic_management import validate_clinic_policy_windows +from shared.exceptions import AppError + + +async def _seed_clinic(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN133", "name": "Admin Clinic", "status": "active"}, + ) + assert clinic.status_code == 201 + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "doc-133", + "clinic_id": clinic_id, + "code": "DOC133", + "display_name": "Dr Admin", + }, + ) + assert doctor.status_code == 201 + return clinic_id, doctor.json()["id"] + + +def test_clinic_policy_validator(): + validate_clinic_policy_windows(booking_lead_time_hours=24, cancellation_window_hours=12) + with pytest.raises(AppError) as exc: + validate_clinic_policy_windows(booking_lead_time_hours=12, cancellation_window_hours=24) + assert exc.value.error_code == "invalid_policy_windows" + + +@pytest.mark.asyncio +async def test_clinic_management_crud(client): + clinic_id, doctor_id = await _seed_clinic(client) + headers = tenant_headers(TENANT_A) + + service = await client.post( + "/api/v1/clinic-services", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "SVC1", + "name": "Consultation", + "duration_minutes": 30, + }, + ) + assert service.status_code == 201 + + staff = await client.post( + "/api/v1/staff-assignments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "code": "STAFF1", + "role_code": "attending", + }, + ) + assert staff.status_code == 201 + + policy = await client.post( + "/api/v1/clinic-policies", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "POL1", + "name": "Booking Policy", + "kind": "booking", + "booking_lead_time_hours": 48, + "cancellation_window_hours": 24, + }, + ) + assert policy.status_code == 201 + + hours = await client.post( + "/api/v1/operating-hours-policies", + headers=headers, + json={ + "clinic_id": clinic_id, + "code": "HRS1", + "name": "Weekday Hours", + "weekly_hours": {"monday": {"open": "08:00", "close": "17:00"}}, + }, + ) + assert hours.status_code == 201 + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.CLINIC_SERVICE_CREATED.value in events + assert HealthcareEventType.STAFF_ASSIGNMENT_CREATED.value in events + + +@pytest.mark.asyncio +async def test_phase_133_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["clinic_management"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_134.py b/backend/services/healthcare/app/tests/test_phase_134.py new file mode 100644 index 0000000..2f8bb79 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_134.py @@ -0,0 +1,132 @@ +"""Phase 13.4 — Patient Portal tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +def patient_headers(tenant_id, patient_id): + h = tenant_headers(tenant_id) + h["X-Patient-ID"] = str(patient_id) + return h + + +async def _seed_patient(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN134", "name": "Portal Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={ + "user_id": "doc-134", + "clinic_id": clinic_id, + "code": "DOC134", + "display_name": "Dr Portal", + }, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "pat-134", + "code": "PAT134", + "display_name": "Portal Patient", + "contact": {"phone": "+989121234567"}, + }, + ) + patient_id = patient.json()["id"] + return clinic_id, doctor_id, patient_id + + +@pytest.mark.asyncio +async def test_patient_portal_profile_and_appointments(client): + clinic_id, doctor_id, patient_id = await _seed_patient(client) + headers = patient_headers(TENANT_A, patient_id) + + profile = await client.get("/api/v1/patient-portal/me", headers=headers) + assert profile.status_code == 200 + assert profile.json()["patient_id"] == patient_id + + updated = await client.patch( + "/api/v1/patient-portal/me", + headers=headers, + json={"preferred_language": "en", "version": profile.json()["version"]}, + ) + assert updated.status_code == 200 + assert updated.json()["preferred_language"] == "en" + + start = datetime(2026, 10, 1, 9, 0, tzinfo=timezone.utc) + booked = await client.post( + "/api/v1/patient-portal/appointments", + headers=headers, + json={ + "clinic_id": clinic_id, + "doctor_id": doctor_id, + "patient_id": patient_id, + "code": "APT-PORTAL", + "scheduled_start": start.isoformat(), + "scheduled_end": (start + timedelta(minutes=30)).isoformat(), + }, + ) + assert booked.status_code == 201 + + listed = await client.get("/api/v1/patient-portal/appointments", headers=headers) + assert listed.status_code == 200 + assert listed.json()["total"] >= 1 + + +@pytest.mark.asyncio +async def test_patient_isolation(client): + _, _, patient_a = await _seed_patient(client) + headers_b = tenant_headers(TENANT_B) + patient_b = await client.post( + "/api/v1/patients", + headers=headers_b, + json={"user_id": "pat-b", "code": "PATB", "display_name": "Other"}, + ) + assert patient_b.status_code == 201 + + denied = await client.get( + "/api/v1/patient-portal/me", + headers=patient_headers(TENANT_A, patient_b.json()["id"]), + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_patient_documents_and_notification_prefs(client): + _, _, patient_id = await _seed_patient(client) + headers = patient_headers(TENANT_A, patient_id) + + doc = await client.post( + "/api/v1/patient-portal/documents", + headers=headers, + json={ + "code": "DOC1", + "title": "Lab Result", + "storage_file_ref": "storage://lab-1", + }, + ) + assert doc.status_code == 201 + + pref = await client.put( + "/api/v1/patient-portal/notification-preferences", + headers=headers, + json={"channel": "sms", "template_key": "healthcare.appointment.reminder", "enabled": True}, + ) + assert pref.status_code == 200 + + +@pytest.mark.asyncio +async def test_phase_134_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["patient_portal"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_135.py b/backend/services/healthcare/app/tests/test_phase_135.py new file mode 100644 index 0000000..9947303 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_135.py @@ -0,0 +1,124 @@ +"""Phase 13.5 — Medical Record tests.""" +from __future__ import annotations + +import pytest + +from app.events.types import HealthcareEventType +from app.events.publisher import get_event_publisher +from app.tests.conftest import TENANT_A, tenant_headers +from app.models.types import EncounterStatus +from app.validators.medical_records import ensure_encounter_mutable +from shared.exceptions import AppError + + +async def _seed_record(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN135", "name": "Record Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={"user_id": "doc-135", "clinic_id": clinic_id, "code": "DOC135", "display_name": "Dr Record"}, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={"user_id": "pat-135", "code": "PAT135", "display_name": "Record Patient"}, + ) + patient_id = patient.json()["id"] + record = await client.post( + "/api/v1/medical-records", + headers=headers, + json={"clinic_id": clinic_id, "patient_id": patient_id, "code": "MR1"}, + ) + assert record.status_code == 201 + return doctor_id, record.json()["id"] + + +def test_encounter_immutability_validator(): + with pytest.raises(AppError) as exc: + ensure_encounter_mutable(EncounterStatus.FINALIZED) + assert exc.value.error_code == "encounter_finalized" + + +@pytest.mark.asyncio +async def test_encounter_finalize_immutability(client): + doctor_id, record_id = await _seed_record(client) + headers = tenant_headers(TENANT_A) + + enc = await client.post( + "/api/v1/encounters", + headers=headers, + json={ + "medical_record_id": record_id, + "doctor_id": doctor_id, + "code": "ENC1", + "chief_complaint": "Headache", + }, + ) + assert enc.status_code == 201 + eid = enc.json()["id"] + version = enc.json()["version"] + + updated = await client.patch( + f"/api/v1/encounters/{eid}", + headers=headers, + json={"clinical_summary": "Migraine suspected", "version": version}, + ) + assert updated.status_code == 200 + version = updated.json()["version"] + + finalized = await client.post( + f"/api/v1/encounters/{eid}/finalize", + headers=headers, + json={"version": version}, + ) + assert finalized.status_code == 200 + assert finalized.json()["status"] == "finalized" + + blocked = await client.patch( + f"/api/v1/encounters/{eid}", + headers=headers, + json={"clinical_summary": "Changed", "version": finalized.json()["version"]}, + ) + assert blocked.status_code == 409 + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.ENCOUNTER_FINALIZED.value in events + + +@pytest.mark.asyncio +async def test_clinical_lists_and_access(client): + _, record_id = await _seed_record(client) + headers = tenant_headers(TENANT_A) + + await client.post( + f"/api/v1/medical-records/{record_id}/conditions", + headers=headers, + json={"code": "COND1", "display": "Hypertension"}, + ) + await client.post( + f"/api/v1/medical-records/{record_id}/allergies", + headers=headers, + json={"code": "ALG1", "substance": "Penicillin"}, + ) + + record = await client.get(f"/api/v1/medical-records/{record_id}", headers=headers) + assert record.status_code == 200 + + lists = await client.get( + f"/api/v1/medical-records/{record_id}/clinical-lists", headers=headers + ) + assert lists.status_code == 200 + assert len(lists.json()["conditions"]) >= 1 + + +@pytest.mark.asyncio +async def test_phase_135_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["medical_records"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_136.py b/backend/services/healthcare/app/tests/test_phase_136.py new file mode 100644 index 0000000..533ae89 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_136.py @@ -0,0 +1,110 @@ +"""Phase 13.6 — Pharmacy Network tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.providers.mocks import MockCommunicationProvider +from app.tests.conftest import TENANT_A, tenant_headers +from app.validators.pharmacy import ensure_prescription_transition +from app.models.types import PrescriptionLifecycleAction, PrescriptionOrderStatus +from shared.exceptions import AppError + + +async def _seed_pharmacy(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN136", "name": "Rx Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={"user_id": "doc-136", "clinic_id": clinic_id, "code": "DOC136", "display_name": "Dr Rx"}, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={ + "user_id": "pat-136", + "code": "PAT136", + "display_name": "Rx Patient", + "contact": {"phone": "+989129999999"}, + }, + ) + patient_id = patient.json()["id"] + pharmacy = await client.post( + "/api/v1/pharmacies", + headers=headers, + json={"code": "PH1", "name": "City Pharmacy"}, + ) + assert pharmacy.status_code == 201 + return doctor_id, patient_id, pharmacy.json()["id"] + + +def test_prescription_lifecycle_validator(): + ensure_prescription_transition( + action=PrescriptionLifecycleAction.ACCEPT, current=PrescriptionOrderStatus.SUBMITTED + ) + with pytest.raises(AppError): + ensure_prescription_transition( + action=PrescriptionLifecycleAction.ACCEPT, current=PrescriptionOrderStatus.READY + ) + + +@pytest.mark.asyncio +async def test_prescription_fulfillment_lifecycle(client): + MockCommunicationProvider.reset() + doctor_id, patient_id, pharmacy_id = await _seed_pharmacy(client) + headers = tenant_headers(TENANT_A) + + order = await client.post( + "/api/v1/prescription-orders", + headers=headers, + json={ + "pharmacy_id": pharmacy_id, + "patient_id": patient_id, + "doctor_id": doctor_id, + "code": "RX1", + "lines": [ + {"line_no": 1, "medication_code": "MED001", "display": "Amoxicillin", "quantity": 1} + ], + }, + ) + assert order.status_code == 201, order.text + oid = order.json()["id"] + version = order.json()["version"] + + for action in ("accept", "prepare", "mark-ready"): + resp = await client.post( + f"/api/v1/prescription-orders/{oid}/{action}", + headers=headers, + json={"version": version}, + ) + assert resp.status_code == 200, resp.text + version = resp.json()["version"] + assert resp.json()["status"] in ("accepted", "preparing", "ready") + + assert len(MockCommunicationProvider.sent) == 1 + + picked = await client.post( + f"/api/v1/prescription-orders/{oid}/complete-pickup", + headers=headers, + json={"version": version}, + ) + assert picked.status_code == 200 + assert picked.json()["status"] == "picked_up" + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.PRESCRIPTION_ORDER_SUBMITTED.value in events + assert HealthcareEventType.PRESCRIPTION_ORDER_STATUS_CHANGED.value in events + + +@pytest.mark.asyncio +async def test_phase_136_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["features"]["pharmacy"] is True diff --git a/backend/services/healthcare/app/tests/test_phase_137.py b/backend/services/healthcare/app/tests/test_phase_137.py new file mode 100644 index 0000000..e7ee845 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_phase_137.py @@ -0,0 +1,119 @@ +"""Phase 13.7 — Delivery Integration tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import HealthcareEventType +from app.providers.mocks import MockDeliveryProvider +from app.tests.conftest import TENANT_A, tenant_headers + + +async def _seed_ready_prescription(client): + headers = tenant_headers(TENANT_A) + clinic = await client.post( + "/api/v1/clinics", + headers=headers, + json={"code": "CLN137", "name": "Delivery Clinic", "status": "active"}, + ) + clinic_id = clinic.json()["id"] + doctor = await client.post( + "/api/v1/doctors", + headers=headers, + json={"user_id": "doc-137", "clinic_id": clinic_id, "code": "DOC137", "display_name": "Dr Del"}, + ) + doctor_id = doctor.json()["id"] + patient = await client.post( + "/api/v1/patients", + headers=headers, + json={"user_id": "pat-137", "code": "PAT137", "display_name": "Del Patient"}, + ) + patient_id = patient.json()["id"] + pharmacy = await client.post( + "/api/v1/pharmacies", + headers=headers, + json={"code": "PH137", "name": "Delivery Pharmacy"}, + ) + pharmacy_id = pharmacy.json()["id"] + order = await client.post( + "/api/v1/prescription-orders", + headers=headers, + json={ + "pharmacy_id": pharmacy_id, + "patient_id": patient_id, + "doctor_id": doctor_id, + "code": "RX137", + "lines": [{"line_no": 1, "medication_code": "M1", "display": "Med", "quantity": 1}], + }, + ) + oid = order.json()["id"] + version = order.json()["version"] + for action in ("accept", "prepare", "mark-ready"): + resp = await client.post( + f"/api/v1/prescription-orders/{oid}/{action}", + headers=headers, + json={"version": version}, + ) + version = resp.json()["version"] + registration = await client.post( + "/api/v1/delivery-integrations", + headers=headers, + json={ + "pharmacy_id": pharmacy_id, + "code": "DELREG1", + "name": "Pharmacy Delivery", + "delivery_merchant_ref": "merchant-137", + }, + ) + assert registration.status_code == 201 + return oid, registration.json()["id"] + + +@pytest.mark.asyncio +async def test_delivery_intent_idempotency_and_webhook(client): + MockDeliveryProvider.reset() + order_id, reg_id = await _seed_ready_prescription(client) + headers = tenant_headers(TENANT_A) + payload = { + "registration_id": reg_id, + "idempotency_key": "idem-137", + "code": "DJ1", + "pickup_address": {"line1": "Pharmacy"}, + "dropoff_address": {"line1": "Home"}, + } + + first = await client.post( + f"/api/v1/delivery-job-intents/prescription-orders/{order_id}", + headers=headers, + json=payload, + ) + assert first.status_code == 201, first.text + ref = first.json()["delivery_job_ref"] + assert ref.startswith("mock-delivery-") + + second = await client.post( + f"/api/v1/delivery-job-intents/prescription-orders/{order_id}", + headers=headers, + json=payload, + ) + assert second.status_code == 201 + assert second.json()["id"] == first.json()["id"] + + webhook = await client.post( + "/api/v1/webhooks/delivery-status", + headers=headers, + json={"delivery_job_ref": ref, "status": "delivered", "external_status": "DELIVERED"}, + ) + assert webhook.status_code == 200 + assert webhook.json()["status"] == "delivered" + + events = {e.event_type for e in get_event_publisher().published} + assert HealthcareEventType.DELIVERY_JOB_INTENT_CREATED.value in events + assert HealthcareEventType.DELIVERY_JOB_STATUS_UPDATED.value in events + + +@pytest.mark.asyncio +async def test_phase_137_capabilities(client): + caps = await client.get("/capabilities") + assert caps.json()["phase"] == "13.7" + assert caps.json()["features"]["delivery_integration"] is True diff --git a/backend/services/healthcare/app/tests/test_security.py b/backend/services/healthcare/app/tests/test_security.py new file mode 100644 index 0000000..20bb814 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_security.py @@ -0,0 +1,40 @@ +"""Security validation — anonymous denial when auth required.""" +from __future__ import annotations + +import os + +import pytest +from httpx import ASGITransport, AsyncClient + + +@pytest.mark.asyncio +async def test_auth_required_denies_anonymous(db_setup): + os.environ["AUTH_REQUIRED"] = "true" + from app.core.config import get_settings + + get_settings.cache_clear() + import app.core.config as config_mod + import app.core.security as security_mod + import app.api.permissions as perm_mod + + config_mod.settings = get_settings() + security_mod.settings = config_mod.settings + perm_mod.settings = config_mod.settings + + from app.main import create_app + + app = create_app() + transport = ASGITransport(app=app) + try: + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + res = await client.get( + "/api/v1/clinics", + headers={"X-Tenant-ID": "11111111-1111-1111-1111-111111111111"}, + ) + assert res.status_code == 401 + finally: + os.environ["AUTH_REQUIRED"] = "false" + get_settings.cache_clear() + config_mod.settings = get_settings() + security_mod.settings = config_mod.settings + perm_mod.settings = config_mod.settings diff --git a/backend/services/healthcare/app/tests/test_tenant.py b/backend/services/healthcare/app/tests/test_tenant.py new file mode 100644 index 0000000..1795856 --- /dev/null +++ b/backend/services/healthcare/app/tests/test_tenant.py @@ -0,0 +1,65 @@ +"""Tenant isolation tests — Phase 13.0.""" +from __future__ import annotations + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def test_tenant_required(client): + res = await client.get("/api/v1/clinics") + assert res.status_code in (400, 422) + + +async def test_clinic_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + + created = await client.post( + "/api/v1/clinics", + headers=headers_a, + json={"code": "TISO", "name": "Isolated Clinic"}, + ) + assert created.status_code == 201 + clinic_id = created.json()["id"] + + other = await client.get(f"/api/v1/clinics/{clinic_id}", headers=headers_b) + assert other.status_code == 404 + + +async def test_doctor_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + + doctor = await client.post( + "/api/v1/doctors", + headers=headers_a, + json={ + "user_id": "iso-doc-1", + "code": "DISO", + "display_name": "Dr. Iso", + }, + ) + assert doctor.status_code == 201 + doctor_id = doctor.json()["id"] + + denied = await client.get(f"/api/v1/doctors/{doctor_id}", headers=headers_b) + assert denied.status_code == 404 + + +async def test_patient_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + + patient = await client.post( + "/api/v1/patients", + headers=headers_a, + json={ + "user_id": "iso-pat-1", + "code": "PISO", + "display_name": "Patient Iso", + }, + ) + assert patient.status_code == 201 + patient_id = patient.json()["id"] + + denied = await client.get(f"/api/v1/patients/{patient_id}", headers=headers_b) + assert denied.status_code == 404 diff --git a/backend/services/healthcare/app/validators/__init__.py b/backend/services/healthcare/app/validators/__init__.py new file mode 100644 index 0000000..90ef2b8 --- /dev/null +++ b/backend/services/healthcare/app/validators/__init__.py @@ -0,0 +1,14 @@ +"""Delivery validators package.""" +from app.validators.foundation import ( + ensure_optimistic_version, + validate_code, + validate_currency_code, + validate_non_empty, +) + +__all__ = [ + "validate_non_empty", + "validate_code", + "validate_currency_code", + "ensure_optimistic_version", +] diff --git a/backend/services/healthcare/app/validators/appointments.py b/backend/services/healthcare/app/validators/appointments.py new file mode 100644 index 0000000..34eb11e --- /dev/null +++ b/backend/services/healthcare/app/validators/appointments.py @@ -0,0 +1,87 @@ +"""Appointment validators — Phase 13.1.""" +from __future__ import annotations + +from datetime import datetime + +from shared.exceptions import AppError + +from app.models.types import AppointmentLifecycleAction, AppointmentStatus + +ALLOWED_TRANSITIONS: dict[AppointmentLifecycleAction, set[AppointmentStatus]] = { + AppointmentLifecycleAction.BOOK: set(), + AppointmentLifecycleAction.CONFIRM: {AppointmentStatus.DRAFT}, + AppointmentLifecycleAction.CHECK_IN: {AppointmentStatus.CONFIRMED}, + AppointmentLifecycleAction.COMPLETE: { + AppointmentStatus.CONFIRMED, + AppointmentStatus.CHECKED_IN, + }, + AppointmentLifecycleAction.CANCEL: { + AppointmentStatus.DRAFT, + AppointmentStatus.CONFIRMED, + AppointmentStatus.CHECKED_IN, + }, + AppointmentLifecycleAction.RESCHEDULE: { + AppointmentStatus.DRAFT, + AppointmentStatus.CONFIRMED, + }, + AppointmentLifecycleAction.MARK_NO_SHOW: {AppointmentStatus.CONFIRMED}, +} + +TARGET_STATUS: dict[AppointmentLifecycleAction, AppointmentStatus] = { + AppointmentLifecycleAction.BOOK: AppointmentStatus.DRAFT, + AppointmentLifecycleAction.CONFIRM: AppointmentStatus.CONFIRMED, + AppointmentLifecycleAction.CHECK_IN: AppointmentStatus.CHECKED_IN, + AppointmentLifecycleAction.COMPLETE: AppointmentStatus.COMPLETED, + AppointmentLifecycleAction.CANCEL: AppointmentStatus.CANCELLED, + AppointmentLifecycleAction.MARK_NO_SHOW: AppointmentStatus.NO_SHOW, +} + +TERMINAL_STATUSES = frozenset( + {AppointmentStatus.COMPLETED, AppointmentStatus.CANCELLED, AppointmentStatus.NO_SHOW} +) + + +def ensure_appointment_lifecycle_transition( + *, action: AppointmentLifecycleAction, current: AppointmentStatus +) -> None: + if current in TERMINAL_STATUSES: + raise AppError( + "نوبت در وضعیت پایانی است", + status_code=409, + error_code="appointment_terminal", + details={"status": current.value, "action": action.value}, + ) + allowed = ALLOWED_TRANSITIONS.get(action, set()) + if action != AppointmentLifecycleAction.BOOK and current not in allowed: + raise AppError( + "انتقال وضعیت نوبت مجاز نیست", + status_code=409, + error_code="invalid_appointment_transition", + details={ + "from_status": current.value, + "action": action.value, + "allowed_from": sorted(s.value for s in allowed), + }, + ) + + +def target_status_for(action: AppointmentLifecycleAction) -> AppointmentStatus: + return TARGET_STATUS[action] + + +def validate_time_window(*, starts_at: datetime, ends_at: datetime) -> None: + if ends_at <= starts_at: + raise AppError( + "بازه زمانی نامعتبر است", + status_code=422, + error_code="invalid_time_window", + ) + + +def validate_schedule_times(*, start_time, end_time) -> None: + if end_time <= start_time: + raise AppError( + "ساعت پایان باید بعد از ساعت شروع باشد", + status_code=422, + error_code="invalid_schedule_times", + ) diff --git a/backend/services/healthcare/app/validators/clinic_management.py b/backend/services/healthcare/app/validators/clinic_management.py new file mode 100644 index 0000000..9c65730 --- /dev/null +++ b/backend/services/healthcare/app/validators/clinic_management.py @@ -0,0 +1,26 @@ +"""Clinic policy validators — Phase 13.3.""" +from __future__ import annotations + +from shared.exceptions import AppError + + +def validate_clinic_policy_windows(*, booking_lead_time_hours: int, cancellation_window_hours: int) -> None: + if cancellation_window_hours > booking_lead_time_hours: + raise AppError( + "پنجره لغو نمی‌تواند بیشتر از زمان پیش‌نیاز رزرو باشد", + status_code=422, + error_code="invalid_policy_windows", + details={ + "booking_lead_time_hours": booking_lead_time_hours, + "cancellation_window_hours": cancellation_window_hours, + }, + ) + + +def validate_weekly_hours(weekly_hours: dict) -> None: + if not weekly_hours: + raise AppError( + "ساعات هفتگی الزامی است", + status_code=422, + error_code="weekly_hours_required", + ) diff --git a/backend/services/healthcare/app/validators/doctor_panel.py b/backend/services/healthcare/app/validators/doctor_panel.py new file mode 100644 index 0000000..93a7341 --- /dev/null +++ b/backend/services/healthcare/app/validators/doctor_panel.py @@ -0,0 +1,42 @@ +"""Doctor panel validators — Phase 13.2.""" +from __future__ import annotations + +from app.models.types import VisitSessionAction, VisitSessionStatus +from shared.exceptions import AppError + +ALLOWED: dict[VisitSessionAction, set[VisitSessionStatus]] = { + VisitSessionAction.CALL_NEXT: {VisitSessionStatus.QUEUED}, + VisitSessionAction.START: {VisitSessionStatus.QUEUED}, + VisitSessionAction.FINISH: {VisitSessionStatus.IN_PROGRESS}, + VisitSessionAction.DEFER: {VisitSessionStatus.QUEUED, VisitSessionStatus.IN_PROGRESS}, +} +TARGET: dict[VisitSessionAction, VisitSessionStatus] = { + VisitSessionAction.CALL_NEXT: VisitSessionStatus.QUEUED, + VisitSessionAction.START: VisitSessionStatus.IN_PROGRESS, + VisitSessionAction.FINISH: VisitSessionStatus.COMPLETED, + VisitSessionAction.DEFER: VisitSessionStatus.DEFERRED, +} +TERMINAL = frozenset({VisitSessionStatus.COMPLETED, VisitSessionStatus.CANCELLED}) + + +def ensure_visit_session_transition( + *, action: VisitSessionAction, current: VisitSessionStatus +) -> None: + if current in TERMINAL: + raise AppError( + "جلسه ویزیت در وضعیت پایانی است", + status_code=409, + error_code="visit_session_terminal", + ) + allowed = ALLOWED.get(action, set()) + if current not in allowed: + raise AppError( + "انتقال وضعیت جلسه ویزیت مجاز نیست", + status_code=409, + error_code="invalid_visit_session_transition", + details={"from_status": current.value, "action": action.value}, + ) + + +def target_status_for(action: VisitSessionAction) -> VisitSessionStatus: + return TARGET[action] diff --git a/backend/services/healthcare/app/validators/foundation.py b/backend/services/healthcare/app/validators/foundation.py new file mode 100644 index 0000000..8f4cecd --- /dev/null +++ b/backend/services/healthcare/app/validators/foundation.py @@ -0,0 +1,55 @@ +"""Reusable validators for Delivery foundation.""" +from __future__ import annotations + +import re +from typing import Any + +from shared.exceptions import AppError + +CODE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{1,49}$") + + +class ValidationError(AppError): + def __init__(self, message: str, details: dict[str, Any] | None = None): + super().__init__( + message=message, + error_code="validation_error", + status_code=422, + details=details or {}, + ) + + +def validate_non_empty(value: str | None, field: str) -> str: + if value is None or not str(value).strip(): + raise ValidationError(f"{field} الزامی است", {"field": field}) + return str(value).strip() + + +def validate_code(code: str) -> str: + code = validate_non_empty(code, "code") + if not CODE_RE.match(code): + raise ValidationError( + "کد نامعتبر است", + {"field": "code", "pattern": CODE_RE.pattern}, + ) + return code + + +def validate_currency_code(code: str) -> str: + code = validate_non_empty(code, "currency_code").upper() + if len(code) != 3: + raise ValidationError("currency_code باید ۳ حرفی باشد", {"field": "currency_code"}) + return code + + +def ensure_optimistic_version(entity: Any, expected: int) -> None: + current = getattr(entity, "version", None) + if current is None: + return + if current != expected: + raise AppError( + "نسخه موجودیت هم‌خوان نیست", + error_code="version_conflict", + status_code=409, + details={"expected": expected, "actual": current}, + ) diff --git a/backend/services/healthcare/app/validators/medical_records.py b/backend/services/healthcare/app/validators/medical_records.py new file mode 100644 index 0000000..7a12eec --- /dev/null +++ b/backend/services/healthcare/app/validators/medical_records.py @@ -0,0 +1,29 @@ +"""Medical record validators — Phase 13.5.""" +from __future__ import annotations + +from app.models.types import EncounterStatus +from shared.exceptions import AppError + + +def ensure_encounter_mutable(status: EncounterStatus) -> None: + if status == EncounterStatus.FINALIZED: + raise AppError( + "Encounter finalized — immutable", + status_code=409, + error_code="encounter_finalized", + ) + + +def ensure_encounter_can_finalize(status: EncounterStatus) -> None: + if status == EncounterStatus.FINALIZED: + raise AppError( + "Encounter already finalized", + status_code=409, + error_code="encounter_already_finalized", + ) + if status not in (EncounterStatus.DRAFT, EncounterStatus.IN_PROGRESS): + raise AppError( + "Encounter cannot be finalized from current status", + status_code=409, + error_code="invalid_encounter_finalize", + ) diff --git a/backend/services/healthcare/app/validators/pharmacy.py b/backend/services/healthcare/app/validators/pharmacy.py new file mode 100644 index 0000000..754368e --- /dev/null +++ b/backend/services/healthcare/app/validators/pharmacy.py @@ -0,0 +1,46 @@ +"""Pharmacy validators — Phase 13.6.""" +from __future__ import annotations + +from app.models.types import PrescriptionLifecycleAction, PrescriptionOrderStatus +from shared.exceptions import AppError + +ALLOWED: dict[PrescriptionLifecycleAction, set[PrescriptionOrderStatus]] = { + PrescriptionLifecycleAction.SUBMIT: set(), + PrescriptionLifecycleAction.ACCEPT: {PrescriptionOrderStatus.SUBMITTED}, + PrescriptionLifecycleAction.PREPARE: {PrescriptionOrderStatus.ACCEPTED}, + PrescriptionLifecycleAction.MARK_READY: {PrescriptionOrderStatus.PREPARING}, + PrescriptionLifecycleAction.COMPLETE_PICKUP: {PrescriptionOrderStatus.READY}, + PrescriptionLifecycleAction.CANCEL: { + PrescriptionOrderStatus.SUBMITTED, + PrescriptionOrderStatus.ACCEPTED, + PrescriptionOrderStatus.PREPARING, + PrescriptionOrderStatus.READY, + }, +} +TARGET: dict[PrescriptionLifecycleAction, PrescriptionOrderStatus] = { + PrescriptionLifecycleAction.SUBMIT: PrescriptionOrderStatus.SUBMITTED, + PrescriptionLifecycleAction.ACCEPT: PrescriptionOrderStatus.ACCEPTED, + PrescriptionLifecycleAction.PREPARE: PrescriptionOrderStatus.PREPARING, + PrescriptionLifecycleAction.MARK_READY: PrescriptionOrderStatus.READY, + PrescriptionLifecycleAction.COMPLETE_PICKUP: PrescriptionOrderStatus.PICKED_UP, + PrescriptionLifecycleAction.CANCEL: PrescriptionOrderStatus.CANCELLED, +} +TERMINAL = frozenset({PrescriptionOrderStatus.PICKED_UP, PrescriptionOrderStatus.CANCELLED}) + + +def ensure_prescription_transition( + *, action: PrescriptionLifecycleAction, current: PrescriptionOrderStatus +) -> None: + if current in TERMINAL: + raise AppError("سفارش نسخه در وضعیت پایانی است", status_code=409, error_code="prescription_terminal") + allowed = ALLOWED.get(action, set()) + if action != PrescriptionLifecycleAction.SUBMIT and current not in allowed: + raise AppError( + "انتقال وضعیت نسخه مجاز نیست", + status_code=409, + error_code="invalid_prescription_transition", + ) + + +def target_prescription_status(action: PrescriptionLifecycleAction) -> PrescriptionOrderStatus: + return TARGET[action] diff --git a/backend/services/healthcare/pytest.ini b/backend/services/healthcare/pytest.ini new file mode 100644 index 0000000..f1f608e --- /dev/null +++ b/backend/services/healthcare/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +testpaths = app/tests +pythonpath = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/services/healthcare/requirements.txt b/backend/services/healthcare/requirements.txt new file mode 100644 index 0000000..bbdf395 --- /dev/null +++ b/backend/services/healthcare/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +pydantic[email]==2.7.4 +pydantic-settings==2.3.4 +sqlalchemy==2.0.31 +alembic==1.13.2 +asyncpg==0.29.0 +psycopg[binary]>=3.2.2 +httpx==0.27.0 +pyjwt[crypto]==2.8.0 +-e ../../shared-lib +pytest==8.2.2 +pytest-asyncio==0.23.7 +aiosqlite==0.20.0 diff --git a/backend/services/healthcare/scripts/app/models/doctor_panel.py b/backend/services/healthcare/scripts/app/models/doctor_panel.py new file mode 100644 index 0000000..796a9d1 --- /dev/null +++ b/backend/services/healthcare/scripts/app/models/doctor_panel.py @@ -0,0 +1,43 @@ +"""Doctor panel aggregates — Phase 13.2.""" +from __future__ import annotations +import uuid +from datetime import datetime +from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON +from app.core.database import Base +from app.models.base import ActorAuditMixin, OptimisticLockMixin, SoftDeleteMixin, TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin +from app.models.types import GUID, VisitSessionStatus + +class VisitSession(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin): + __tablename__ = "visit_sessions" + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + appointment_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[VisitSessionStatus] = mapped_column(default=VisitSessionStatus.QUEUED, nullable=False) + queue_position: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_sessions_tenant_code"), + Index("ix_visit_sessions_doctor", "tenant_id", "doctor_id"), + Index("ix_visit_sessions_appointment", "tenant_id", "appointment_id"), + ) + +class VisitNote(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin): + __tablename__ = "visit_notes" + visit_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_notes_tenant_code"), + Index("ix_visit_notes_session", "tenant_id", "visit_session_id"), + ) + diff --git a/backend/services/healthcare/scripts/ensure_db.py b/backend/services/healthcare/scripts/ensure_db.py new file mode 100644 index 0000000..7eaa56b --- /dev/null +++ b/backend/services/healthcare/scripts/ensure_db.py @@ -0,0 +1,41 @@ +"""Ensure healthcare_db exists before migration.""" +from __future__ import annotations + +import os +import sys +from urllib.parse import urlparse + + +def main() -> None: + sync_url = os.environ.get("HEALTHCARE_DATABASE_URL_SYNC", "") + if not sync_url: + print("HEALTHCARE_DATABASE_URL_SYNC not set", file=sys.stderr) + return + + parsed = urlparse(sync_url.replace("+psycopg", "")) + db_name = (parsed.path or "").lstrip("/") or "healthcare_db" + + import psycopg + + conn = psycopg.connect( + host=parsed.hostname or "localhost", + port=parsed.port or 5432, + user=parsed.username, + password=parsed.password, + dbname="postgres", + autocommit=True, + ) + try: + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,)) + if cur.fetchone() is None: + cur.execute(f'CREATE DATABASE "{db_name}"') + print(f"Created database: {db_name}") + else: + print(f"Database exists: {db_name}") + finally: + conn.close() + + +if __name__ == "__main__": + main() diff --git a/backend/services/healthcare/scripts/gen_phases.py b/backend/services/healthcare/scripts/gen_phases.py new file mode 100644 index 0000000..d09123c --- /dev/null +++ b/backend/services/healthcare/scripts/gen_phases.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Generate healthcare phases 13.2-13.7 implementation files.""" +from __future__ import annotations + +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + +FILES: dict[str, str] = {} + + +def add(path: str, content: str) -> None: + FILES[path] = textwrap.dedent(content).lstrip() + "\n" + + +# --- Phase 13.2 models --- +add( + "app/models/doctor_panel.py", + '''\ + """Doctor panel aggregates — Phase 13.2.""" + from __future__ import annotations + import uuid + from datetime import datetime + from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint + from sqlalchemy.orm import Mapped, mapped_column + from sqlalchemy.types import JSON + from app.core.database import Base + from app.models.base import ActorAuditMixin, OptimisticLockMixin, SoftDeleteMixin, TenantMixin, TimestampMixin, UUIDPrimaryKeyMixin + from app.models.types import GUID, VisitSessionStatus + + class VisitSession(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin): + __tablename__ = "visit_sessions" + clinic_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + patient_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + appointment_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[VisitSessionStatus] = mapped_column(default=VisitSessionStatus.QUEUED, nullable=False) + queue_position: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_sessions_tenant_code"), + Index("ix_visit_sessions_doctor", "tenant_id", "doctor_id"), + Index("ix_visit_sessions_appointment", "tenant_id", "appointment_id"), + ) + + class VisitNote(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin, SoftDeleteMixin, ActorAuditMixin, OptimisticLockMixin): + __tablename__ = "visit_notes" + visit_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + doctor_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_visit_notes_tenant_code"), + Index("ix_visit_notes_session", "tenant_id", "visit_session_id"), + ) + ''', +) + +# I'll write the generator output directly via Write tool for key files instead +# since the script approach is getting complex. Let me write phase files directly. + +if __name__ == "__main__": + for rel, content in FILES.items(): + path = ROOT / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(f"wrote {rel}") diff --git a/backend/services/healthcare/scripts/implement_phases.py b/backend/services/healthcare/scripts/implement_phases.py new file mode 100644 index 0000000..5dd146e --- /dev/null +++ b/backend/services/healthcare/scripts/implement_phases.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""Generate healthcare phases 13.2-13.7 implementation files.""" +from __future__ import annotations + +import textwrap +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +FILES: dict[str, str] = {} + + +def add(path: str, content: str) -> None: + FILES[path] = textwrap.dedent(content).lstrip() + "\n" + + +# ============================================================================= +# Phase 13.2 — Doctor Panel +# ============================================================================= + +add( + "app/schemas/doctor_panel.py", + '''\ + """Doctor panel DTOs — Phase 13.2.""" + from __future__ import annotations + from datetime import datetime + from uuid import UUID + from pydantic import BaseModel, ConfigDict, Field + from app.models.types import VisitSessionStatus + from app.schemas.common import ORMBase + from app.schemas.appointments import AppointmentRead + + _FORBID = ConfigDict(extra="forbid") + + class VisitSessionCreate(BaseModel): + model_config = _FORBID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + appointment_id: UUID + code: str = Field(max_length=50) + queue_position: int = Field(default=100, ge=1, le=10000) + metadata_json: dict | None = None + + class VisitSessionActionRequest(BaseModel): + model_config = _FORBID + version: int = Field(ge=1) + reason: str | None = Field(default=None, max_length=500) + + class VisitSessionRead(ORMBase): + id: UUID + tenant_id: UUID + clinic_id: UUID + doctor_id: UUID + patient_id: UUID + appointment_id: UUID + code: str + status: VisitSessionStatus + queue_position: int + started_at: datetime | None + finished_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + class VisitNoteCreate(BaseModel): + model_config = _FORBID + visit_session_id: UUID + doctor_id: UUID + code: str = Field(max_length=50) + title: str = Field(max_length=255) + body: str | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + class VisitNoteUpdate(BaseModel): + model_config = _FORBID + title: str | None = Field(default=None, max_length=255) + body: str | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + version: int = Field(ge=1) + metadata_json: dict | None = None + + class VisitNoteRead(ORMBase): + id: UUID + tenant_id: UUID + visit_session_id: UUID + doctor_id: UUID + code: str + title: str + body: str | None + storage_file_ref: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + class DoctorDashboardRead(BaseModel): + doctor_id: UUID + queued_count: int + in_progress_count: int + completed_today_count: int + upcoming_appointments: list[AppointmentRead] + + class DoctorQueueRead(BaseModel): + doctor_id: UUID + items: list[VisitSessionRead] + ''', +) + +add( + "app/validators/doctor_panel.py", + '''\ + """Doctor panel validators — Phase 13.2.""" + from __future__ import annotations + from shared.exceptions import AppError + from app.models.types import VisitSessionAction, VisitSessionStatus + + ALLOWED: dict[VisitSessionAction, set[VisitSessionStatus]] = { + VisitSessionAction.CALL_NEXT: {VisitSessionStatus.QUEUED}, + VisitSessionAction.START: {VisitSessionStatus.QUEUED}, + VisitSessionAction.FINISH: {VisitSessionStatus.IN_PROGRESS}, + VisitSessionAction.DEFER: {VisitSessionStatus.QUEUED, VisitSessionStatus.IN_PROGRESS}, + } + TARGET: dict[VisitSessionAction, VisitSessionStatus] = { + VisitSessionAction.CALL_NEXT: VisitSessionStatus.QUEUED, + VisitSessionAction.START: VisitSessionStatus.IN_PROGRESS, + VisitSessionAction.FINISH: VisitSessionStatus.COMPLETED, + VisitSessionAction.DEFER: VisitSessionStatus.DEFERRED, + } + TERMINAL = frozenset({VisitSessionStatus.COMPLETED, VisitSessionStatus.CANCELLED}) + + def ensure_visit_session_transition(*, action: VisitSessionAction, current: VisitSessionStatus) -> None: + if current in TERMINAL: + raise AppError("جلسه ویزیت در وضعیت پایانی است", status_code=409, error_code="visit_session_terminal") + allowed = ALLOWED.get(action, set()) + if current not in allowed: + raise AppError( + "انتقال وضعیت جلسه ویزیت مجاز نیست", + status_code=409, + error_code="invalid_visit_session_transition", + details={"from_status": current.value, "action": action.value}, + ) + + def target_status_for(action: VisitSessionAction) -> VisitSessionStatus: + return TARGET[action] + ''', +) + +add( + "app/policies/doctor_panel.py", + '''\ + """Visit session lifecycle policies — Phase 13.2.""" + from __future__ import annotations + from app.models.types import VisitSessionAction, VisitSessionStatus + from app.validators.doctor_panel import ensure_visit_session_transition, target_status_for + + class VisitSessionPolicy: + def ensure_transition(self, *, action: VisitSessionAction, current: VisitSessionStatus) -> VisitSessionStatus: + ensure_visit_session_transition(action=action, current=current) + return target_status_for(action) + ''', +) + +add( + "app/specifications/doctor_panel.py", + '''\ + """Doctor panel list specifications — Phase 13.2.""" + from __future__ import annotations + from dataclasses import dataclass + from uuid import UUID + from app.models.types import VisitSessionStatus + + ALLOWED_SORT_FIELDS = frozenset({"queue_position", "created_at", "started_at"}) + + @dataclass + class VisitSessionListSpec: + doctor_id: UUID | None = None + clinic_id: UUID | None = None + status: VisitSessionStatus | None = None + sort_by: str = "queue_position" + sort_dir: str = "asc" + ''', +) + +add( + "app/repositories/doctor_panel.py", + '''\ + """Doctor panel repositories — Phase 13.2.""" + from __future__ import annotations + from datetime import datetime, timezone + from uuid import UUID + from sqlalchemy import and_, func, select + from app.models.doctor_panel import VisitNote, VisitSession + from app.models.types import VisitSessionStatus + from app.repositories.base import TenantBaseRepository + from app.specifications.doctor_panel import VisitSessionListSpec + + class VisitSessionRepository(TenantBaseRepository[VisitSession]): + model = VisitSession + + async def get_by_code(self, tenant_id: UUID, code: str) -> VisitSession | None: + stmt = select(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.code == code, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_appointment(self, tenant_id: UUID, appointment_id: UUID) -> VisitSession | None: + stmt = select(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.appointment_id == appointment_id, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + def _filter_clauses(self, tenant_id: UUID, spec: VisitSessionListSpec | None): + clauses = [VisitSession.tenant_id == tenant_id, VisitSession.is_deleted.is_(False)] + if spec is None: + return clauses + if spec.doctor_id is not None: + clauses.append(VisitSession.doctor_id == spec.doctor_id) + if spec.clinic_id is not None: + clauses.append(VisitSession.clinic_id == spec.clinic_id) + if spec.status is not None: + clauses.append(VisitSession.status == spec.status) + return clauses + + async def list_filtered(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: VisitSessionListSpec | None = None): + clauses = self._filter_clauses(tenant_id, spec) + sort_col = getattr(VisitSession, spec.sort_by if spec else "queue_position", VisitSession.queue_position) + order = sort_col.asc() if spec and spec.sort_dir == "asc" else sort_col.desc() + stmt = select(VisitSession).where(and_(*clauses)).order_by(order).offset(offset).limit(limit) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_by_status(self, tenant_id: UUID, *, doctor_id: UUID, status: VisitSessionStatus) -> int: + stmt = select(func.count()).select_from(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.doctor_id == doctor_id, + VisitSession.status == status, + VisitSession.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def count_completed_today(self, tenant_id: UUID, *, doctor_id: UUID) -> int: + today = datetime.now(timezone.utc).date() + stmt = select(func.count()).select_from(VisitSession).where( + VisitSession.tenant_id == tenant_id, + VisitSession.doctor_id == doctor_id, + VisitSession.status == VisitSessionStatus.COMPLETED, + VisitSession.is_deleted.is_(False), + func.date(VisitSession.finished_at) == today, + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + class VisitNoteRepository(TenantBaseRepository[VisitNote]): + model = VisitNote + + async def get_by_code(self, tenant_id: UUID, code: str) -> VisitNote | None: + stmt = select(VisitNote).where( + VisitNote.tenant_id == tenant_id, + VisitNote.code == code, + VisitNote.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_session(self, tenant_id: UUID, visit_session_id: UUID): + stmt = select(VisitNote).where( + VisitNote.tenant_id == tenant_id, + VisitNote.visit_session_id == visit_session_id, + VisitNote.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalars().all() + ''', +) + +if __name__ == "__main__": + for rel, content in FILES.items(): + path = ROOT / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(f"wrote {rel}") diff --git a/frontend/docs/healthcare-api-connection-report.md b/frontend/docs/healthcare-api-connection-report.md new file mode 100644 index 0000000..bca0c84 --- /dev/null +++ b/frontend/docs/healthcare-api-connection-report.md @@ -0,0 +1,118 @@ +# Healthcare API Connection Report + +**Service:** `healthcare-service` v0.13.7.0 +**BFF:** `/api/healthcare/*` → port 8010 +**Client:** `frontend/src/modules/healthcare/services/healthcare-api.ts` + +--- + +## Connection Architecture + +``` +Browser (portal page) + → healthcareApi.* (tenant + JWT headers) + → /api/healthcare/* (Next.js route handler) + → healthcare-service:8010/api/v1/* +``` + +All mutations use optimistic invalidation of TanStack Query keys scoped by tenant. + +--- + +## API Surface Used by UI + +### Foundation (Phase 13.0) + +| Resource | List | Get | Create | Update | Delete | UI Screen | +|----------|------|-----|--------|--------|--------|-----------| +| clinics | ✅ | ✅ | ✅ | ✅ | ✅ | `ClinicsResourcePage` | +| branches | ✅ | ✅ | ✅ | ✅ | ✅ | `BranchesResourcePage` | +| departments | ✅ | ✅ | ✅ | ✅ | ✅ | `DepartmentsResourcePage` | +| doctors | ✅ | ✅ | ✅ | ✅ | ✅ | `DoctorsResourcePage` | +| patients | ✅ | ✅ | ✅ | ✅ | ✅ | `PatientsResourcePage` | +| external_providers | ✅ | ✅ | ✅ | ✅ | ✅ | `ExternalProvidersResourcePage` | +| settings | ✅ | — | upsert | upsert | — | `SettingsResourcePage` | +| audit | ✅ | — | — | — | — | `AuditExplorerPage` | +| permissions/catalog | ✅ | — | — | — | — | `PermissionsCatalogPage` | + +### Appointments & Visits (13.1–13.2) + +| Resource | Operations wired in UI | +|----------|------------------------| +| appointments | list, book, **confirm**, **cancel**, **no-show** | +| waiting_list | list, create | +| visit_sessions | list, create, **start**, **finish**, **defer**, **callNext** | +| schedules | list, create | +| availability_windows | list, create | +| doctor_panel/dashboard | doctor dashboard stats | +| visit_notes | via doctor encounters (create in records flow) | + +### Clinic Admin (13.3) + +| Resource | UI | +|----------|-----| +| clinic_services | CRUD create + list | +| staff_assignments | CRUD create + list | +| clinic_policies | CRUD create + list | +| operating_hours_policies | list (via resources) | + +### Patient Portal (13.4) + +| Endpoint | UI Page | +|----------|---------| +| patient-portal/me | Patient profile | +| patient-portal/appointments | upcoming / past / cancel | +| POST patient-portal/appointments | book wizard | +| patient-portal/documents | lab results, reviews, attachments | +| patient-portal/notification-preferences | notifications toggle | + +### Medical Records (13.5–13.6) + +| Endpoint | UI | +|----------|-----| +| medical-records POST/GET | lookup panel + create | +| clinical-lists + conditions/allergies/medications/immunizations | tabs + add | +| encounters create/update/finalize | encounters panel | +| prescription-orders full pipeline | doctor + pharmacy portals | +| pharmacies | admin + pharmacy dashboard | + +### Delivery (13.7) + +| Endpoint | UI | +|----------|-----| +| delivery-integrations | pharmacy delivery page | +| delivery-job-intents | pharmacy delivery page | + +### Integrations (accounting / CRM / communication) + +Mapped via `external_providers` filtered by `provider_kind` on payments, messages, income pages. + +--- + +## Headers & Auth + +| Header | Source | +|--------|--------| +| `Authorization` | Keycloak JWT via `getValidAccessToken()` | +| `X-Tenant-ID` | `useTenantId()` | +| `X-Patient-ID` | patient portal routes only | + +401 triggers silent refresh via `refreshSession()`. + +--- + +## Validation + +| Check | Result | +|-------|--------| +| All entity screens call `healthcareApi` | ✅ | +| No hardcoded mock arrays in module pages | ✅ | +| BFF route exists | `app/api/healthcare/[...path]/route.ts` | +| Error mapping | `HealthcareApiError` with code + message | + +--- + +## Gaps (backend) + +- Collection list for `medical-records` and `encounters` not in client API — UI uses ID lookup / create flows. +- Dedicated insurance, laboratory, imaging request resources not in v0.13.7 OpenAPI — documented as future endpoints. diff --git a/frontend/docs/healthcare-ui-completion-report.md b/frontend/docs/healthcare-ui-completion-report.md new file mode 100644 index 0000000..2325efd --- /dev/null +++ b/frontend/docs/healthcare-ui-completion-report.md @@ -0,0 +1,92 @@ +# Healthcare UI Completion Report + +**Date:** 2026-07-26 +**Scope:** All existing `/healthcare/*` routes (114 pages) +**Module root:** `frontend/src/modules/healthcare` + +--- + +## Summary + +Healthcare portal pages were upgraded from read-only list views to **enterprise CRUD workspaces** backed by the real `healthcareApi` client. A shared `HealthcareResourceWorkspace` engine powers tables, cards, search, saved filters, create/edit dialogs, workflow actions, audit drawers, and permission gates. + +--- + +## Architecture + +| Layer | Location | Role | +|-------|----------|------| +| Routes | `app/healthcare/**` | Thin re-exports only | +| Business UI | `src/modules/healthcare/pages/*` | Portal-specific composition | +| Entity screens | `src/modules/healthcare/components/entities/*` | Full CRUD + workflows | +| CRUD engine | `src/modules/healthcare/components/crud/HealthcareResourceWorkspace.tsx` | Reusable enterprise table | +| Shared UI | `src/shared/ui`, `src/shared/hooks` | Timeline, calendar, filters | +| API | `src/modules/healthcare/services/healthcare-api.ts` | Real BFF → :8010 | + +--- + +## Portal Completion Matrix + +| Portal | Pages | CRUD / Workflows | Calendar | Audit | Permissions | +|--------|-------|------------------|----------|-------|-------------| +| Admin | 14 | Clinics, doctors, patients, pharmacies, settings, audit | — | ✅ | ✅ catalog | +| Reception | 10 | Appointments, queue, walk-in, check-in/out | ✅ | ✅ | ✅ | +| Doctor | 16 | Schedule, availability, appointments, Rx, records | ✅ | ✅ | ✅ doctor gate | +| Patient | 18 | Book/cancel appointments, docs, notifications, profile | — | ✅ | ✅ patient gate | +| Clinic | 11 | Departments, branches, staff, services, policies | — | ✅ | ✅ | +| Hospital | 9 | Departments, beds, staff, appointments | — | ✅ | ✅ | +| Pharmacy | 7 | Rx workflow (accept→ready→pickup), delivery | — | ✅ | ✅ | +| Public site | 17 | Search, book, profiles (existing + API) | — | — | public | +| Hub / Mobile | 3 | Portal launcher | — | — | — | + +--- + +## Enterprise Features Delivered + +- **CRUD:** create / edit / delete dialogs for foundation entities +- **Workflow actions:** appointment confirm/cancel/no-show; visit start/finish/defer; prescription pharmacy pipeline +- **Search & filters:** debounced search, status filter, saved filter presets (localStorage) +- **Views:** responsive table + card toggle +- **Calendar:** `AppointmentCalendar` for reception & doctor schedules +- **Audit:** entity detail drawer + timeline from `/api/v1/audit` +- **Permissions:** `useHealthcarePermissions` + `PermissionDeniedState`; backend enforces 403 +- **Patient portal:** book wizard, cancel appointment, document upload refs, notification prefs +- **Loading / empty / error states:** unified via shared DS + healthcare page shells +- **RTL / theme:** inherits platform CSS vars, dark mode, `--health-accent*` tokens + +--- + +## Key New Files + +``` +src/shared/hooks/useDebouncedValue.ts +src/shared/hooks/useSavedFilters.ts +src/shared/ui/Timeline.tsx +src/shared/ui/AppointmentCalendar.tsx +src/shared/ui/PermissionDeniedState.tsx +src/modules/healthcare/components/crud/HealthcareResourceWorkspace.tsx +src/modules/healthcare/components/entities/foundation.tsx +src/modules/healthcare/components/entities/workflows.tsx +src/modules/healthcare/components/entities/clinical.tsx +src/modules/healthcare/hooks/useHealthcarePermissions.ts +src/modules/healthcare/constants/permissions.ts +``` + +--- + +## Known Limitations + +1. **Encounters list API** — backend exposes create/update/finalize but no collection list; UI supports create + placeholder list until list endpoint ships. +2. **Medical records list** — no list endpoint; lookup-by-ID panel + create form provided. +3. **Insurance / lab / imaging** — not exposed as dedicated REST resources in healthcare service v0.13.7; patient lab results mapped to `patient-portal/documents` with `document_type` filter. +4. **Full build** — Next.js production build fails on pre-existing `modules/hospitality` TypeScript error (`z` namespace); healthcare bundle compiles successfully. + +--- + +## Self-Audit + +- [x] No placeholder buttons on upgraded portal pages +- [x] No mock data in module business code +- [x] Module boundary respected (`validate:healthcare` PASS) +- [x] 114 routes intact +- [x] Zero circular deps in healthcare module (32 files) diff --git a/frontend/docs/healthcare-validation-report.md b/frontend/docs/healthcare-validation-report.md new file mode 100644 index 0000000..e765b91 --- /dev/null +++ b/frontend/docs/healthcare-validation-report.md @@ -0,0 +1,108 @@ +# Healthcare Validation Report + +**Date:** 2026-07-26 +**Environment:** Windows dev / TorbatYar monorepo frontend + +--- + +## Quality Gates + +| Gate | Command | Result | Notes | +|------|---------|--------|-------| +| TypeScript | `npm run typecheck` | ⚠️ PASS healthcare | 1 pre-existing beauty error unrelated | +| ESLint | `npm run lint` | ✅ PASS | Healthcare hooks rule fixed | +| Healthcare imports | `npm run validate:healthcare-imports` | ✅ PASS | | +| Healthcare routes | `npm run validate:healthcare-routes` | ✅ PASS | 114 pages | +| Circular deps | `npm run validate:healthcare-circular` | ✅ PASS | 32 module files | +| Architecture | `npm run validate:architecture` | ✅ PASS | | +| Production build | `npm run build` | ⚠️ BLOCKED | Hospitality TS error (`z` namespace) — healthcare compiled OK in 5.6min | + +--- + +## CRUD Validation (manual / code review) + +| Entity | Create | Read | Update | Delete | Workflow | +|--------|--------|------|--------|--------|----------| +| clinics | ✅ dialog | ✅ table | ✅ dialog | ✅ confirm | — | +| branches | ✅ | ✅ | ✅ | ✅ | — | +| departments | ✅ | ✅ | ✅ | ✅ | — | +| doctors | ✅ | ✅ | ✅ | ✅ | — | +| patients | ✅ | ✅ | ✅ | ✅ | — | +| appointments | ✅ book | ✅ | — | — | confirm/cancel/no-show | +| visit_sessions | ✅ | ✅ | — | — | start/finish/defer/call | +| prescription_orders | ✅ | ✅ | — | — | accept/prepare/ready/pickup/cancel | +| waiting_list | ✅ | ✅ | — | — | — | +| external_providers | ✅ | ✅ | ✅ | ✅ | — | + +--- + +## Permission Validation + +- `useHealthcarePermissions` grants manage to tenant owners / platform admin +- View routes require authenticated tenant + `healthcare.*.view` keys (UI gate) +- API 403 surfaces `PermissionDeniedState` +- Permissions catalog page lists all backend-defined keys + +--- + +## Responsive Validation + +- `HealthcareResourceWorkspace`: table scroll + card grid (`sm:grid-cols-2 xl:grid-cols-3`) +- `TableToolbar` stacks on mobile (`flex-col sm:flex-row`) +- `AppointmentCalendar`: horizontal scroll (`overflow-x-auto`, min-width 720px) +- Portal shells unchanged — mobile portal route `/healthcare/mobile/[portal]` + +--- + +## Accessibility Validation + +- Dialogs: `role="dialog"`, `aria-modal`, Escape to close +- Drawer: labelled title `aria-labelledby` +- Timeline: semantic `
    ` / `