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:
parent
f89ca52e02
commit
625275e55b
22
backend/services/healthcare/Dockerfile.dev
Normal file
22
backend/services/healthcare/Dockerfile.dev
Normal 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
|
||||
64
backend/services/healthcare/README.md
Normal file
64
backend/services/healthcare/README.md
Normal 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.1–13.7)
|
||||
- Delivery dispatch engines
|
||||
- Frontend UI
|
||||
|
||||
## APIs
|
||||
|
||||
| Method | Path |
|
||||
| --- | --- |
|
||||
| GET | `/health` |
|
||||
| GET | `/capabilities` |
|
||||
| GET | `/metrics` |
|
||||
| CRUD | `/api/v1/clinics` |
|
||||
| CRUD | `/api/v1/branches` |
|
||||
| CRUD | `/api/v1/departments` |
|
||||
| CRUD | `/api/v1/doctors` |
|
||||
| CRUD | `/api/v1/patients` |
|
||||
| CRUD | `/api/v1/external-providers` |
|
||||
| CRUD | `/api/v1/configurations` |
|
||||
| PUT/GET | `/api/v1/settings` |
|
||||
| GET | `/api/v1/audit` |
|
||||
| GET | `/api/v1/permissions/catalog` |
|
||||
|
||||
## Run (dev)
|
||||
|
||||
```bash
|
||||
export HEALTHCARE_DATABASE_URL=postgresql+asyncpg://...
|
||||
export HEALTHCARE_DATABASE_URL_SYNC=postgresql+psycopg://...
|
||||
python scripts/ensure_db.py
|
||||
alembic upgrade head
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8010 --reload
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
41
backend/services/healthcare/alembic.ini
Normal file
41
backend/services/healthcare/alembic.ini
Normal 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
|
||||
42
backend/services/healthcare/alembic/env.py
Normal file
42
backend/services/healthcare/alembic/env.py
Normal 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()
|
||||
19
backend/services/healthcare/alembic/versions/0001_initial.py
Normal file
19
backend/services/healthcare/alembic/versions/0001_initial.py
Normal 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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
1
backend/services/healthcare/app/__init__.py
Normal file
1
backend/services/healthcare/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "0.13.7.0"
|
||||
39
backend/services/healthcare/app/api/deps.py
Normal file
39
backend/services/healthcare/app/api/deps.py
Normal 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
|
||||
22
backend/services/healthcare/app/api/patient_deps.py
Normal file
22
backend/services/healthcare/app/api/patient_deps.py
Normal 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
|
||||
48
backend/services/healthcare/app/api/permissions.py
Normal file
48
backend/services/healthcare/app/api/permissions.py
Normal 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
|
||||
101
backend/services/healthcare/app/api/v1/__init__.py
Normal file
101
backend/services/healthcare/app/api/v1/__init__.py
Normal 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"]
|
||||
)
|
||||
233
backend/services/healthcare/app/api/v1/appointments.py
Normal file
233
backend/services/healthcare/app/api/v1/appointments.py
Normal 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
|
||||
)
|
||||
30
backend/services/healthcare/app/api/v1/audit.py
Normal file
30
backend/services/healthcare/app/api/v1/audit.py
Normal 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
|
||||
)
|
||||
75
backend/services/healthcare/app/api/v1/branches.py
Normal file
75
backend/services/healthcare/app/api/v1/branches.py
Normal 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)
|
||||
119
backend/services/healthcare/app/api/v1/clinic_management.py
Normal file
119
backend/services/healthcare/app/api/v1/clinic_management.py
Normal 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
|
||||
)
|
||||
75
backend/services/healthcare/app/api/v1/clinics.py
Normal file
75
backend/services/healthcare/app/api/v1/clinics.py
Normal 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)
|
||||
83
backend/services/healthcare/app/api/v1/configurations.py
Normal file
83
backend/services/healthcare/app/api/v1/configurations.py
Normal 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
|
||||
)
|
||||
@ -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)
|
||||
75
backend/services/healthcare/app/api/v1/departments.py
Normal file
75
backend/services/healthcare/app/api/v1/departments.py
Normal 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)
|
||||
183
backend/services/healthcare/app/api/v1/doctor_panel.py
Normal file
183
backend/services/healthcare/app/api/v1/doctor_panel.py
Normal 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)
|
||||
75
backend/services/healthcare/app/api/v1/doctors.py
Normal file
75
backend/services/healthcare/app/api/v1/doctors.py
Normal 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)
|
||||
83
backend/services/healthcare/app/api/v1/external_providers.py
Normal file
83
backend/services/healthcare/app/api/v1/external_providers.py
Normal 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
|
||||
)
|
||||
90
backend/services/healthcare/app/api/v1/health.py
Normal file
90
backend/services/healthcare/app/api/v1/health.py
Normal 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.0–13.7 complete",
|
||||
}
|
||||
151
backend/services/healthcare/app/api/v1/medical_records.py
Normal file
151
backend/services/healthcare/app/api/v1/medical_records.py
Normal 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)
|
||||
128
backend/services/healthcare/app/api/v1/patient_portal.py
Normal file
128
backend/services/healthcare/app/api/v1/patient_portal.py
Normal 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
|
||||
)
|
||||
75
backend/services/healthcare/app/api/v1/patients.py
Normal file
75
backend/services/healthcare/app/api/v1/patients.py
Normal 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)
|
||||
27
backend/services/healthcare/app/api/v1/permissions.py
Normal file
27
backend/services/healthcare/app/api/v1/permissions.py
Normal 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),
|
||||
}
|
||||
138
backend/services/healthcare/app/api/v1/pharmacy.py
Normal file
138
backend/services/healthcare/app/api/v1/pharmacy.py
Normal 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)
|
||||
39
backend/services/healthcare/app/api/v1/settings.py
Normal file
39
backend/services/healthcare/app/api/v1/settings.py
Normal 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
|
||||
)
|
||||
4
backend/services/healthcare/app/commands/__init__.py
Normal file
4
backend/services/healthcare/app/commands/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Profile commands package."""
|
||||
from app.commands.profiles import ProfileCommands
|
||||
|
||||
__all__ = ["ProfileCommands"]
|
||||
72
backend/services/healthcare/app/commands/appointments.py
Normal file
72
backend/services/healthcare/app/commands/appointments.py
Normal 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)
|
||||
@ -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)
|
||||
81
backend/services/healthcare/app/commands/doctor_panel.py
Normal file
81
backend/services/healthcare/app/commands/doctor_panel.py
Normal 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)
|
||||
46
backend/services/healthcare/app/commands/profiles.py
Normal file
46
backend/services/healthcare/app/commands/profiles.py
Normal 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)
|
||||
75
backend/services/healthcare/app/core/config.py
Normal file
75
backend/services/healthcare/app/core/config.py
Normal 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()
|
||||
27
backend/services/healthcare/app/core/database.py
Normal file
27
backend/services/healthcare/app/core/database.py
Normal 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
|
||||
14
backend/services/healthcare/app/core/logging.py
Normal file
14
backend/services/healthcare/app/core/logging.py
Normal 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)
|
||||
49
backend/services/healthcare/app/core/security.py
Normal file
49
backend/services/healthcare/app/core/security.py
Normal 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)
|
||||
121
backend/services/healthcare/app/events/publisher.py
Normal file
121
backend/services/healthcare/app/events/publisher.py
Normal 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
|
||||
63
backend/services/healthcare/app/events/types.py
Normal file
63
backend/services/healthcare/app/events/types.py
Normal 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"
|
||||
139
backend/services/healthcare/app/main.py
Normal file
139
backend/services/healthcare/app/main.py
Normal 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()
|
||||
|
||||
|
||||
24
backend/services/healthcare/app/middlewares/tenant.py
Normal file
24
backend/services/healthcare/app/middlewares/tenant.py
Normal 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)
|
||||
53
backend/services/healthcare/app/models/__init__.py
Normal file
53
backend/services/healthcare/app/models/__init__.py
Normal 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,
|
||||
)
|
||||
164
backend/services/healthcare/app/models/appointments.py
Normal file
164
backend/services/healthcare/app/models/appointments.py
Normal 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"),
|
||||
)
|
||||
51
backend/services/healthcare/app/models/base.py
Normal file
51
backend/services/healthcare/app/models/base.py
Normal 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)
|
||||
126
backend/services/healthcare/app/models/clinic_management.py
Normal file
126
backend/services/healthcare/app/models/clinic_management.py
Normal 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"),
|
||||
)
|
||||
100
backend/services/healthcare/app/models/delivery_integration.py
Normal file
100
backend/services/healthcare/app/models/delivery_integration.py
Normal 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",
|
||||
),
|
||||
)
|
||||
76
backend/services/healthcare/app/models/doctor_panel.py
Normal file
76
backend/services/healthcare/app/models/doctor_panel.py
Normal 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"),
|
||||
)
|
||||
296
backend/services/healthcare/app/models/foundation.py
Normal file
296
backend/services/healthcare/app/models/foundation.py
Normal 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",
|
||||
),
|
||||
)
|
||||
178
backend/services/healthcare/app/models/medical_records.py
Normal file
178
backend/services/healthcare/app/models/medical_records.py
Normal 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"),
|
||||
)
|
||||
44
backend/services/healthcare/app/models/outbox.py
Normal file
44
backend/services/healthcare/app/models/outbox.py
Normal 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"),
|
||||
)
|
||||
94
backend/services/healthcare/app/models/patient_portal.py
Normal file
94
backend/services/healthcare/app/models/patient_portal.py
Normal 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"),
|
||||
)
|
||||
124
backend/services/healthcare/app/models/pharmacy.py
Normal file
124
backend/services/healthcare/app/models/pharmacy.py
Normal 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"),
|
||||
)
|
||||
85
backend/services/healthcare/app/models/profiles.py
Normal file
85
backend/services/healthcare/app/models/profiles.py
Normal 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"),
|
||||
)
|
||||
175
backend/services/healthcare/app/models/types.py
Normal file
175
backend/services/healthcare/app/models/types.py
Normal 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"
|
||||
183
backend/services/healthcare/app/permissions/definitions.py
Normal file
183
backend/services/healthcare/app/permissions/definitions.py
Normal 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.",
|
||||
)
|
||||
4
backend/services/healthcare/app/policies/__init__.py
Normal file
4
backend/services/healthcare/app/policies/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Healthcare domain policies package."""
|
||||
from app.policies.appointments import AppointmentLifecyclePolicy
|
||||
|
||||
__all__ = ["AppointmentLifecyclePolicy"]
|
||||
13
backend/services/healthcare/app/policies/appointments.py
Normal file
13
backend/services/healthcare/app/policies/appointments.py
Normal 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)
|
||||
13
backend/services/healthcare/app/policies/doctor_panel.py
Normal file
13
backend/services/healthcare/app/policies/doctor_panel.py
Normal 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)
|
||||
25
backend/services/healthcare/app/policies/medical_records.py
Normal file
25
backend/services/healthcare/app/policies/medical_records.py
Normal 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",
|
||||
)
|
||||
13
backend/services/healthcare/app/policies/pharmacy.py
Normal file
13
backend/services/healthcare/app/policies/pharmacy.py
Normal 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)
|
||||
11
backend/services/healthcare/app/providers/__init__.py
Normal file
11
backend/services/healthcare/app/providers/__init__.py
Normal file
@ -0,0 +1,11 @@
|
||||
"""Platform provider contracts package."""
|
||||
from app.providers.contracts import ( # noqa: F401
|
||||
AIProvider,
|
||||
AccountingProvider,
|
||||
CRMProvider,
|
||||
CommunicationProvider,
|
||||
DeliveryProvider,
|
||||
IdentityProvider,
|
||||
LoyaltyProvider,
|
||||
StorageProvider,
|
||||
)
|
||||
75
backend/services/healthcare/app/providers/contracts.py
Normal file
75
backend/services/healthcare/app/providers/contracts.py
Normal 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]: ...
|
||||
89
backend/services/healthcare/app/providers/mocks.py
Normal file
89
backend/services/healthcare/app/providers/mocks.py
Normal 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"}
|
||||
4
backend/services/healthcare/app/queries/__init__.py
Normal file
4
backend/services/healthcare/app/queries/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Profile queries package."""
|
||||
from app.queries.profiles import ProfileQueries
|
||||
|
||||
__all__ = ["ProfileQueries"]
|
||||
49
backend/services/healthcare/app/queries/appointments.py
Normal file
49
backend/services/healthcare/app/queries/appointments.py
Normal 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)
|
||||
37
backend/services/healthcare/app/queries/doctor_panel.py
Normal file
37
backend/services/healthcare/app/queries/doctor_panel.py
Normal 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)
|
||||
26
backend/services/healthcare/app/queries/profiles.py
Normal file
26
backend/services/healthcare/app/queries/profiles.py
Normal 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)
|
||||
153
backend/services/healthcare/app/repositories/appointments.py
Normal file
153
backend/services/healthcare/app/repositories/appointments.py
Normal 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()
|
||||
78
backend/services/healthcare/app/repositories/base.py
Normal file
78
backend/services/healthcare/app/repositories/base.py
Normal 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())
|
||||
@ -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()
|
||||
@ -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
|
||||
119
backend/services/healthcare/app/repositories/doctor_panel.py
Normal file
119
backend/services/healthcare/app/repositories/doctor_panel.py
Normal 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()
|
||||
150
backend/services/healthcare/app/repositories/foundation.py
Normal file
150
backend/services/healthcare/app/repositories/foundation.py
Normal 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()
|
||||
116
backend/services/healthcare/app/repositories/medical_records.py
Normal file
116
backend/services/healthcare/app/repositories/medical_records.py
Normal 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()
|
||||
@ -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()
|
||||
52
backend/services/healthcare/app/repositories/pharmacy.py
Normal file
52
backend/services/healthcare/app/repositories/pharmacy.py
Normal 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
|
||||
53
backend/services/healthcare/app/repositories/profiles.py
Normal file
53
backend/services/healthcare/app/repositories/profiles.py
Normal 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()
|
||||
182
backend/services/healthcare/app/schemas/appointments.py
Normal file
182
backend/services/healthcare/app/schemas/appointments.py
Normal 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
|
||||
126
backend/services/healthcare/app/schemas/clinic_management.py
Normal file
126
backend/services/healthcare/app/schemas/clinic_management.py
Normal 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
|
||||
18
backend/services/healthcare/app/schemas/common.py
Normal file
18
backend/services/healthcare/app/schemas/common.py
Normal 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
|
||||
@ -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
|
||||
98
backend/services/healthcare/app/schemas/doctor_panel.py
Normal file
98
backend/services/healthcare/app/schemas/doctor_panel.py
Normal 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]
|
||||
284
backend/services/healthcare/app/schemas/foundation.py
Normal file
284
backend/services/healthcare/app/schemas/foundation.py
Normal 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
|
||||
168
backend/services/healthcare/app/schemas/medical_records.py
Normal file
168
backend/services/healthcare/app/schemas/medical_records.py
Normal 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)
|
||||
99
backend/services/healthcare/app/schemas/patient_portal.py
Normal file
99
backend/services/healthcare/app/schemas/patient_portal.py
Normal 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
|
||||
102
backend/services/healthcare/app/schemas/pharmacy.py
Normal file
102
backend/services/healthcare/app/schemas/pharmacy.py
Normal 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
|
||||
109
backend/services/healthcare/app/schemas/profiles.py
Normal file
109
backend/services/healthcare/app/schemas/profiles.py
Normal 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
|
||||
485
backend/services/healthcare/app/services/appointments.py
Normal file
485
backend/services/healthcare/app/services/appointments.py
Normal 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))
|
||||
51
backend/services/healthcare/app/services/audit_service.py
Normal file
51
backend/services/healthcare/app/services/audit_service.py
Normal 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
|
||||
)
|
||||
215
backend/services/healthcare/app/services/clinic_management.py
Normal file
215
backend/services/healthcare/app/services/clinic_management.py
Normal 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))
|
||||
199
backend/services/healthcare/app/services/delivery_integration.py
Normal file
199
backend/services/healthcare/app/services/delivery_integration.py
Normal 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))
|
||||
390
backend/services/healthcare/app/services/doctor_panel.py
Normal file
390
backend/services/healthcare/app/services/doctor_panel.py
Normal 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))
|
||||
786
backend/services/healthcare/app/services/foundation.py
Normal file
786
backend/services/healthcare/app/services/foundation.py
Normal 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
Loading…
Reference in New Issue
Block a user