药品流向管理内容优化-初步

This commit is contained in:
Cheng Zhou
2026-01-13 19:42:28 +08:00
parent 1db36f40b0
commit ef1e67218c
21 changed files with 344 additions and 480 deletions
-36
View File
@@ -1,36 +0,0 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://postgres:postgres@db:5432/ctms
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[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
-71
View File
@@ -1,71 +0,0 @@
from __future__ import annotations
import asyncio
import os
import sys
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.core.config import settings
from app.db.base import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def _get_database_url() -> str:
url = os.getenv("DATABASE_URL", settings.DATABASE_URL)
if url and url.startswith("postgresql+asyncpg://"):
return url
return url
def run_migrations_offline() -> None:
url = _get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = _get_database_url()
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
@@ -1,50 +0,0 @@
"""create users table with review status"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "20240501_000001"
down_revision = None
branch_labels = None
depends_on = None
user_role_enum = sa.Enum("ADMIN", "PM", "CRA", "PV", "IMP", name="user_role")
user_status_enum = sa.Enum("PENDING", "ACTIVE", "REJECTED", "DISABLED", name="user_status")
def upgrade() -> None:
bind = op.get_bind()
user_role_enum.create(bind, checkfirst=True)
user_status_enum.create(bind, checkfirst=True)
op.create_table(
"users",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, nullable=False),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("password_hash", sa.String(length=255), nullable=False),
sa.Column("full_name", sa.String(length=255), nullable=False),
sa.Column("role", user_role_enum, nullable=False),
sa.Column("department", sa.String(length=255), nullable=False),
sa.Column("status", user_status_enum, nullable=False, server_default="PENDING"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("approved_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True),
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_users_email", "users", ["email"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")
bind = op.get_bind()
user_role_enum.drop(bind, checkfirst=True)
user_status_enum.drop(bind, checkfirst=True)
@@ -1,19 +0,0 @@
"""add avatar_url to users"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20240501_000002"
down_revision = "20240501_000001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("avatar_url", sa.String(length=500), nullable=True))
def downgrade() -> None:
op.drop_column("users", "avatar_url")
@@ -1,22 +0,0 @@
"""drop visit_name from visits"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20240501_000003"
down_revision = "20240501_000002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("visits", "visit_name")
def downgrade() -> None:
op.add_column(
"visits",
sa.Column("visit_name", sa.String(length=200), nullable=False, server_default=""),
)
@@ -1,25 +0,0 @@
"""add visit template fields to studies"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20240501_000004"
down_revision = "20240501_000003"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("studies", sa.Column("visit_interval_days", sa.Integer(), nullable=True))
op.add_column("studies", sa.Column("visit_total", sa.Integer(), nullable=True))
op.add_column("studies", sa.Column("visit_window_start_offset", sa.Integer(), nullable=True))
op.add_column("studies", sa.Column("visit_window_end_offset", sa.Integer(), nullable=True))
def downgrade() -> None:
op.drop_column("studies", "visit_window_end_offset")
op.drop_column("studies", "visit_window_start_offset")
op.drop_column("studies", "visit_total")
op.drop_column("studies", "visit_interval_days")
@@ -1,19 +0,0 @@
"""add consent_date to subjects"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "20240501_000005"
down_revision = "20240501_000004"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("subjects", sa.Column("consent_date", sa.Date(), nullable=True))
def downgrade() -> None:
op.drop_column("subjects", "consent_date")
@@ -1,104 +0,0 @@
"""create fee tables
Revision ID: 20240501_000006
Revises: 20240501_000005
Create Date: 2025-02-14 00:00:00
"""
from alembic import op
import sqlalchemy as sa
revision = "20240501_000006"
down_revision = "20240501_000005"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"contract_fees",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("project_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("center_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("contract_amount", sa.Numeric(12, 2), nullable=False),
sa.Column("contract_cases", sa.Integer(), nullable=False),
sa.Column("actual_cases", sa.Integer(), nullable=True),
sa.Column("settlement_amount", sa.Numeric(12, 2), nullable=True),
sa.Column("final_payment_amount", sa.Numeric(12, 2), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["studies.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["center_id"], ["sites.id"], ondelete="RESTRICT"),
sa.UniqueConstraint("project_id", "center_id", name="uq_contract_fees_project_center"),
)
op.create_index("ix_contract_fees_project_id", "contract_fees", ["project_id"])
op.create_index("ix_contract_fees_center_id", "contract_fees", ["center_id"])
op.create_table(
"contract_fee_payments",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("contract_fee_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("seq", sa.Integer(), nullable=False),
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
sa.Column("paid_date", sa.Date(), nullable=True),
sa.Column("verified_date", sa.Date(), nullable=True),
sa.Column("is_paid", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("is_verified", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("remark", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["contract_fee_id"], ["contract_fees.id"], ondelete="CASCADE"),
)
op.create_index("ix_contract_fee_payments_contract_fee_id", "contract_fee_payments", ["contract_fee_id"])
op.create_table(
"special_expenses",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("project_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("center_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("category", sa.String(50), nullable=False),
sa.Column("amount", sa.Numeric(12, 2), nullable=False),
sa.Column("happen_date", sa.Date(), nullable=True),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("is_paid", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("paid_date", sa.Date(), nullable=True),
sa.Column("is_verified", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.Column("verified_date", sa.Date(), nullable=True),
sa.Column("created_by", sa.dialects.postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.ForeignKeyConstraint(["project_id"], ["studies.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["center_id"], ["sites.id"], ondelete="RESTRICT"),
sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="RESTRICT"),
)
op.create_index("ix_special_expenses_project_id", "special_expenses", ["project_id"])
op.create_index("ix_special_expenses_center_id", "special_expenses", ["center_id"])
op.create_table(
"fee_attachments",
sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column("entity_type", sa.String(50), nullable=False),
sa.Column("entity_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("file_type", sa.String(30), nullable=False),
sa.Column("filename", sa.String(255), nullable=False),
sa.Column("mime_type", sa.String(100), nullable=True),
sa.Column("size", sa.Integer(), nullable=False),
sa.Column("storage_key", sa.String(500), nullable=True),
sa.Column("url", sa.Text(), nullable=True),
sa.Column("uploaded_by", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False),
sa.Column("uploaded_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("is_deleted", sa.Boolean(), server_default=sa.text("false"), nullable=False),
sa.ForeignKeyConstraint(["uploaded_by"], ["users.id"], ondelete="RESTRICT"),
)
def downgrade() -> None:
op.drop_table("fee_attachments")
op.drop_index("ix_special_expenses_center_id", table_name="special_expenses")
op.drop_index("ix_special_expenses_project_id", table_name="special_expenses")
op.drop_table("special_expenses")
op.drop_index("ix_contract_fee_payments_contract_fee_id", table_name="contract_fee_payments")
op.drop_table("contract_fee_payments")
op.drop_index("ix_contract_fees_center_id", table_name="contract_fees")
op.drop_index("ix_contract_fees_project_id", table_name="contract_fees")
op.drop_table("contract_fees")
+21 -1
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles
from app.crud import audit as audit_crud
from app.crud import drug_shipment as shipment_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate
@@ -32,6 +33,10 @@ async def create_shipment(
current_user=Depends(get_current_user),
) -> DrugShipmentRead:
await _ensure_study_exists(db, study_id)
site = await site_crud.get_site(db, shipment_in.center_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不存在")
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id)
await audit_crud.log_action(
db,
@@ -53,8 +58,10 @@ async def create_shipment(
)
async def list_shipments(
study_id: uuid.UUID,
center_id: uuid.UUID | None = None,
site_name: str | None = None,
tracking_no: str | None = None,
direction: str | None = None,
status: str | None = None,
skip: int = 0,
limit: int = 100,
@@ -62,7 +69,15 @@ async def list_shipments(
) -> list[DrugShipmentRead]:
await _ensure_study_exists(db, study_id)
items = await shipment_crud.list_shipments(
db, study_id, site_name=site_name, tracking_no=tracking_no, status=status, skip=skip, limit=limit
db,
study_id,
center_id=center_id,
site_name=site_name,
tracking_no=tracking_no,
direction=direction,
status=status,
skip=skip,
limit=limit,
)
return [DrugShipmentRead.model_validate(item) for item in items]
@@ -100,6 +115,11 @@ async def update_shipment(
shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在")
if shipment_in.center_id:
site = await site_crud.get_site(db, shipment_in.center_id)
if not site or site.study_id != study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分中心不存在")
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
await audit_crud.log_action(
db,
+11 -4
View File
@@ -17,13 +17,14 @@ async def create_shipment(
shipment = DrugShipment(
study_id=study_id,
direction=shipment_in.direction,
site_name=shipment_in.site_name,
drug_desc=shipment_in.drug_desc,
center_id=shipment_in.center_id,
site_name=shipment_in.site_name or "",
ship_date=shipment_in.ship_date,
receive_date=shipment_in.receive_date,
quantity=shipment_in.quantity,
batch_no=shipment_in.batch_no,
carrier=shipment_in.carrier,
tracking_no=shipment_in.tracking_no,
from_party=shipment_in.from_party,
to_party=shipment_in.to_party,
status=shipment_in.status,
remark=shipment_in.remark,
created_by=created_by,
@@ -42,17 +43,23 @@ async def get_shipment(db: AsyncSession, shipment_id: uuid.UUID) -> DrugShipment
async def list_shipments(
db: AsyncSession,
study_id: uuid.UUID,
center_id: uuid.UUID | None = None,
site_name: str | None = None,
tracking_no: str | None = None,
direction: str | None = None,
status: str | None = None,
skip: int = 0,
limit: int = 100,
) -> Sequence[DrugShipment]:
stmt = select(DrugShipment).where(DrugShipment.study_id == study_id)
if center_id:
stmt = stmt.where(DrugShipment.center_id == center_id)
if site_name:
stmt = stmt.where(DrugShipment.site_name.ilike(f"%{site_name}%"))
if tracking_no:
stmt = stmt.where(DrugShipment.tracking_no.ilike(f"%{tracking_no}%"))
if direction:
stmt = stmt.where(DrugShipment.direction == direction)
if status:
stmt = stmt.where(DrugShipment.status == status)
stmt = stmt.order_by(DrugShipment.created_at.desc()).offset(skip).limit(limit)
+5 -4
View File
@@ -1,7 +1,7 @@
import uuid
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, String, Text, func
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -14,13 +14,14 @@ class DrugShipment(Base):
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False)
direction: Mapped[str] = mapped_column(String(20), nullable=False)
center_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), index=True, nullable=True)
site_name: Mapped[str] = mapped_column(String(255), nullable=False)
drug_desc: Mapped[str] = mapped_column(Text, nullable=False)
ship_date: Mapped[date | None] = mapped_column(Date, nullable=True)
receive_date: Mapped[date | None] = mapped_column(Date, nullable=True)
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
batch_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
carrier: Mapped[str | None] = mapped_column(String(100), nullable=True)
tracking_no: Mapped[str | None] = mapped_column(String(100), nullable=True)
from_party: Mapped[str | None] = mapped_column(String(255), nullable=True)
to_party: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(String(30), nullable=False)
remark: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
+17 -14
View File
@@ -7,26 +7,28 @@ from pydantic import BaseModel, ConfigDict
class DrugShipmentCreate(BaseModel):
direction: str
site_name: str
drug_desc: str
ship_date: Optional[date] = None
carrier: Optional[str] = None
tracking_no: Optional[str] = None
from_party: Optional[str] = None
to_party: Optional[str] = None
center_id: uuid.UUID
site_name: Optional[str] = None
ship_date: date
receive_date: date
quantity: int
batch_no: str
carrier: str
tracking_no: str
status: str
remark: Optional[str] = None
remark: str
class DrugShipmentUpdate(BaseModel):
direction: Optional[str] = None
center_id: Optional[uuid.UUID] = None
site_name: Optional[str] = None
drug_desc: Optional[str] = None
ship_date: Optional[date] = None
receive_date: Optional[date] = None
quantity: Optional[int] = None
batch_no: Optional[str] = None
carrier: Optional[str] = None
tracking_no: Optional[str] = None
from_party: Optional[str] = None
to_party: Optional[str] = None
status: Optional[str] = None
remark: Optional[str] = None
@@ -35,13 +37,14 @@ class DrugShipmentRead(BaseModel):
id: uuid.UUID
study_id: uuid.UUID
direction: str
center_id: Optional[uuid.UUID]
site_name: str
drug_desc: str
ship_date: Optional[date]
receive_date: Optional[date]
quantity: Optional[int]
batch_no: Optional[str]
carrier: Optional[str]
tracking_no: Optional[str]
from_party: Optional[str]
to_party: Optional[str]
status: str
remark: Optional[str]
created_by: Optional[uuid.UUID]
-1
View File
@@ -2,7 +2,6 @@ fastapi==0.104.1
uvicorn[standard]==0.24.0.post1
sqlalchemy==2.0.23
asyncpg==0.29.0
alembic==1.13.1
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4