feat(healthcare): add backend service and finalize frontend build

Add the Healthcare platform backend (phases 13.0–13.7) with Alembic migration revision fix, production deploy script, validation reports, shared UI exports, and loyalty list page client directives so Next.js build succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-27 11:38:31 +03:30
parent f89ca52e02
commit 625275e55b
149 changed files with 13779 additions and 0 deletions

View File

@ -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

View File

@ -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.113.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
```

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1 @@
__version__ = "0.13.7.0"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"]
)

View File

@ -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
)

View File

@ -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
)

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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
)

View File

@ -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.013.7 complete",
}

View File

@ -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)

View File

@ -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
)

View File

@ -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)

View File

@ -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),
}

View File

@ -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)

View File

@ -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
)

View File

@ -0,0 +1,4 @@
"""Profile commands package."""
from app.commands.profiles import ProfileCommands
__all__ = ["ProfileCommands"]

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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()

View File

@ -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)

View File

@ -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,
)

View File

@ -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"),
)

View File

@ -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)

View File

@ -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"),
)

View File

@ -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",
),
)

View File

@ -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"),
)

View File

@ -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",
),
)

View File

@ -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"),
)

View File

@ -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"),
)

View File

@ -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"),
)

View File

@ -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"),
)

View File

@ -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"),
)

View File

@ -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"

View File

@ -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.",
)

View File

@ -0,0 +1,4 @@
"""Healthcare domain policies package."""
from app.policies.appointments import AppointmentLifecyclePolicy
__all__ = ["AppointmentLifecyclePolicy"]

View File

@ -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)

View File

@ -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)

View File

@ -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",
)

View File

@ -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)

View File

@ -0,0 +1,11 @@
"""Platform provider contracts package."""
from app.providers.contracts import ( # noqa: F401
AIProvider,
AccountingProvider,
CRMProvider,
CommunicationProvider,
DeliveryProvider,
IdentityProvider,
LoyaltyProvider,
StorageProvider,
)

View File

@ -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]: ...

View File

@ -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"}

View File

@ -0,0 +1,4 @@
"""Profile queries package."""
from app.queries.profiles import ProfileQueries
__all__ = ["ProfileQueries"]

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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())

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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
)

View File

@ -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))

View File

@ -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))

View File

@ -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))

View File

@ -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))

Some files were not shown because too many files have changed in this diff Show More