TorbatYar/backend/services/healthcare/scripts/implement_phases.py
Mortezakoohjani 625275e55b feat(healthcare): add backend service and finalize frontend build
Add the Healthcare platform backend (phases 13.0–13.7) with Alembic migration revision fix, production deploy script, validation reports, shared UI exports, and loyalty list page client directives so Next.js build succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:38:31 +03:30

293 lines
11 KiB
Python

#!/usr/bin/env python3
"""Generate healthcare phases 13.2-13.7 implementation files."""
from __future__ import annotations
import textwrap
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FILES: dict[str, str] = {}
def add(path: str, content: str) -> None:
FILES[path] = textwrap.dedent(content).lstrip() + "\n"
# =============================================================================
# Phase 13.2 — Doctor Panel
# =============================================================================
add(
"app/schemas/doctor_panel.py",
'''\
"""Doctor panel DTOs — Phase 13.2."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from app.models.types import VisitSessionStatus
from app.schemas.common import ORMBase
from app.schemas.appointments import AppointmentRead
_FORBID = ConfigDict(extra="forbid")
class VisitSessionCreate(BaseModel):
model_config = _FORBID
clinic_id: UUID
doctor_id: UUID
patient_id: UUID
appointment_id: UUID
code: str = Field(max_length=50)
queue_position: int = Field(default=100, ge=1, le=10000)
metadata_json: dict | None = None
class VisitSessionActionRequest(BaseModel):
model_config = _FORBID
version: int = Field(ge=1)
reason: str | None = Field(default=None, max_length=500)
class VisitSessionRead(ORMBase):
id: UUID
tenant_id: UUID
clinic_id: UUID
doctor_id: UUID
patient_id: UUID
appointment_id: UUID
code: str
status: VisitSessionStatus
queue_position: int
started_at: datetime | None
finished_at: datetime | None
metadata_json: dict | None
version: int
is_deleted: bool
created_at: datetime
updated_at: datetime
class VisitNoteCreate(BaseModel):
model_config = _FORBID
visit_session_id: UUID
doctor_id: UUID
code: str = Field(max_length=50)
title: str = Field(max_length=255)
body: str | None = None
storage_file_ref: str | None = Field(default=None, max_length=255)
metadata_json: dict | None = None
class VisitNoteUpdate(BaseModel):
model_config = _FORBID
title: str | None = Field(default=None, max_length=255)
body: str | None = None
storage_file_ref: str | None = Field(default=None, max_length=255)
version: int = Field(ge=1)
metadata_json: dict | None = None
class VisitNoteRead(ORMBase):
id: UUID
tenant_id: UUID
visit_session_id: UUID
doctor_id: UUID
code: str
title: str
body: str | None
storage_file_ref: str | None
metadata_json: dict | None
version: int
is_deleted: bool
created_at: datetime
updated_at: datetime
class DoctorDashboardRead(BaseModel):
doctor_id: UUID
queued_count: int
in_progress_count: int
completed_today_count: int
upcoming_appointments: list[AppointmentRead]
class DoctorQueueRead(BaseModel):
doctor_id: UUID
items: list[VisitSessionRead]
''',
)
add(
"app/validators/doctor_panel.py",
'''\
"""Doctor panel validators — Phase 13.2."""
from __future__ import annotations
from shared.exceptions import AppError
from app.models.types import VisitSessionAction, VisitSessionStatus
ALLOWED: dict[VisitSessionAction, set[VisitSessionStatus]] = {
VisitSessionAction.CALL_NEXT: {VisitSessionStatus.QUEUED},
VisitSessionAction.START: {VisitSessionStatus.QUEUED},
VisitSessionAction.FINISH: {VisitSessionStatus.IN_PROGRESS},
VisitSessionAction.DEFER: {VisitSessionStatus.QUEUED, VisitSessionStatus.IN_PROGRESS},
}
TARGET: dict[VisitSessionAction, VisitSessionStatus] = {
VisitSessionAction.CALL_NEXT: VisitSessionStatus.QUEUED,
VisitSessionAction.START: VisitSessionStatus.IN_PROGRESS,
VisitSessionAction.FINISH: VisitSessionStatus.COMPLETED,
VisitSessionAction.DEFER: VisitSessionStatus.DEFERRED,
}
TERMINAL = frozenset({VisitSessionStatus.COMPLETED, VisitSessionStatus.CANCELLED})
def ensure_visit_session_transition(*, action: VisitSessionAction, current: VisitSessionStatus) -> None:
if current in TERMINAL:
raise AppError("جلسه ویزیت در وضعیت پایانی است", status_code=409, error_code="visit_session_terminal")
allowed = ALLOWED.get(action, set())
if current not in allowed:
raise AppError(
"انتقال وضعیت جلسه ویزیت مجاز نیست",
status_code=409,
error_code="invalid_visit_session_transition",
details={"from_status": current.value, "action": action.value},
)
def target_status_for(action: VisitSessionAction) -> VisitSessionStatus:
return TARGET[action]
''',
)
add(
"app/policies/doctor_panel.py",
'''\
"""Visit session lifecycle policies — Phase 13.2."""
from __future__ import annotations
from app.models.types import VisitSessionAction, VisitSessionStatus
from app.validators.doctor_panel import ensure_visit_session_transition, target_status_for
class VisitSessionPolicy:
def ensure_transition(self, *, action: VisitSessionAction, current: VisitSessionStatus) -> VisitSessionStatus:
ensure_visit_session_transition(action=action, current=current)
return target_status_for(action)
''',
)
add(
"app/specifications/doctor_panel.py",
'''\
"""Doctor panel list specifications — Phase 13.2."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from app.models.types import VisitSessionStatus
ALLOWED_SORT_FIELDS = frozenset({"queue_position", "created_at", "started_at"})
@dataclass
class VisitSessionListSpec:
doctor_id: UUID | None = None
clinic_id: UUID | None = None
status: VisitSessionStatus | None = None
sort_by: str = "queue_position"
sort_dir: str = "asc"
''',
)
add(
"app/repositories/doctor_panel.py",
'''\
"""Doctor panel repositories — Phase 13.2."""
from __future__ import annotations
from datetime import datetime, timezone
from uuid import UUID
from sqlalchemy import and_, func, select
from app.models.doctor_panel import VisitNote, VisitSession
from app.models.types import VisitSessionStatus
from app.repositories.base import TenantBaseRepository
from app.specifications.doctor_panel import VisitSessionListSpec
class VisitSessionRepository(TenantBaseRepository[VisitSession]):
model = VisitSession
async def get_by_code(self, tenant_id: UUID, code: str) -> VisitSession | None:
stmt = select(VisitSession).where(
VisitSession.tenant_id == tenant_id,
VisitSession.code == code,
VisitSession.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_by_appointment(self, tenant_id: UUID, appointment_id: UUID) -> VisitSession | None:
stmt = select(VisitSession).where(
VisitSession.tenant_id == tenant_id,
VisitSession.appointment_id == appointment_id,
VisitSession.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
def _filter_clauses(self, tenant_id: UUID, spec: VisitSessionListSpec | None):
clauses = [VisitSession.tenant_id == tenant_id, VisitSession.is_deleted.is_(False)]
if spec is None:
return clauses
if spec.doctor_id is not None:
clauses.append(VisitSession.doctor_id == spec.doctor_id)
if spec.clinic_id is not None:
clauses.append(VisitSession.clinic_id == spec.clinic_id)
if spec.status is not None:
clauses.append(VisitSession.status == spec.status)
return clauses
async def list_filtered(self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: VisitSessionListSpec | None = None):
clauses = self._filter_clauses(tenant_id, spec)
sort_col = getattr(VisitSession, spec.sort_by if spec else "queue_position", VisitSession.queue_position)
order = sort_col.asc() if spec and spec.sort_dir == "asc" else sort_col.desc()
stmt = select(VisitSession).where(and_(*clauses)).order_by(order).offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_by_status(self, tenant_id: UUID, *, doctor_id: UUID, status: VisitSessionStatus) -> int:
stmt = select(func.count()).select_from(VisitSession).where(
VisitSession.tenant_id == tenant_id,
VisitSession.doctor_id == doctor_id,
VisitSession.status == status,
VisitSession.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return int(result.scalar_one())
async def count_completed_today(self, tenant_id: UUID, *, doctor_id: UUID) -> int:
today = datetime.now(timezone.utc).date()
stmt = select(func.count()).select_from(VisitSession).where(
VisitSession.tenant_id == tenant_id,
VisitSession.doctor_id == doctor_id,
VisitSession.status == VisitSessionStatus.COMPLETED,
VisitSession.is_deleted.is_(False),
func.date(VisitSession.finished_at) == today,
)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class VisitNoteRepository(TenantBaseRepository[VisitNote]):
model = VisitNote
async def get_by_code(self, tenant_id: UUID, code: str) -> VisitNote | None:
stmt = select(VisitNote).where(
VisitNote.tenant_id == tenant_id,
VisitNote.code == code,
VisitNote.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_by_session(self, tenant_id: UUID, visit_session_id: UUID):
stmt = select(VisitNote).where(
VisitNote.tenant_id == tenant_id,
VisitNote.visit_session_id == visit_session_id,
VisitNote.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalars().all()
''',
)
if __name__ == "__main__":
for rel, content in FILES.items():
path = ROOT / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
print(f"wrote {rel}")