diff --git a/backend/alembic.ini b/backend/alembic.ini deleted file mode 100644 index 2f774c88..00000000 --- a/backend/alembic.ini +++ /dev/null @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py deleted file mode 100644 index 314a01ea..00000000 --- a/backend/alembic/env.py +++ /dev/null @@ -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()) diff --git a/backend/alembic/versions/20240501_000001_create_users_table.py b/backend/alembic/versions/20240501_000001_create_users_table.py deleted file mode 100644 index f052340c..00000000 --- a/backend/alembic/versions/20240501_000001_create_users_table.py +++ /dev/null @@ -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) diff --git a/backend/alembic/versions/20240501_000002_add_avatar_to_users.py b/backend/alembic/versions/20240501_000002_add_avatar_to_users.py deleted file mode 100644 index a9df55a5..00000000 --- a/backend/alembic/versions/20240501_000002_add_avatar_to_users.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20240501_000003_drop_visit_name_from_visits.py b/backend/alembic/versions/20240501_000003_drop_visit_name_from_visits.py deleted file mode 100644 index b7546f44..00000000 --- a/backend/alembic/versions/20240501_000003_drop_visit_name_from_visits.py +++ /dev/null @@ -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=""), - ) diff --git a/backend/alembic/versions/20240501_000004_add_visit_template_to_studies.py b/backend/alembic/versions/20240501_000004_add_visit_template_to_studies.py deleted file mode 100644 index 93a6f36d..00000000 --- a/backend/alembic/versions/20240501_000004_add_visit_template_to_studies.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20240501_000005_add_consent_date_to_subjects.py b/backend/alembic/versions/20240501_000005_add_consent_date_to_subjects.py deleted file mode 100644 index 29122119..00000000 --- a/backend/alembic/versions/20240501_000005_add_consent_date_to_subjects.py +++ /dev/null @@ -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") diff --git a/backend/alembic/versions/20240501_000006_create_fee_tables.py b/backend/alembic/versions/20240501_000006_create_fee_tables.py deleted file mode 100644 index f45f53a4..00000000 --- a/backend/alembic/versions/20240501_000006_create_fee_tables.py +++ /dev/null @@ -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") diff --git a/backend/app/api/v1/drug_shipments.py b/backend/app/api/v1/drug_shipments.py index a0c30497..c3237c10 100644 --- a/backend/app/api/v1/drug_shipments.py +++ b/backend/app/api/v1/drug_shipments.py @@ -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, diff --git a/backend/app/crud/drug_shipment.py b/backend/app/crud/drug_shipment.py index 48824c15..ad8348cd 100644 --- a/backend/app/crud/drug_shipment.py +++ b/backend/app/crud/drug_shipment.py @@ -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) diff --git a/backend/app/models/drug_shipment.py b/backend/app/models/drug_shipment.py index 010c5644..d17af665 100644 --- a/backend/app/models/drug_shipment.py +++ b/backend/app/models/drug_shipment.py @@ -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) diff --git a/backend/app/schemas/drug_shipment.py b/backend/app/schemas/drug_shipment.py index be0e9055..5bdd18ec 100644 --- a/backend/app/schemas/drug_shipment.py +++ b/backend/app/schemas/drug_shipment.py @@ -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] diff --git a/backend/requirements.txt b/backend/requirements.txt index 857d57f2..49b58b76 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/database/init.sql b/database/init.sql index 0f6cdc99..a300698b 100644 --- a/database/init.sql +++ b/database/init.sql @@ -300,19 +300,21 @@ CREATE TABLE IF NOT EXISTS public.drug_shipments ( id uuid PRIMARY KEY, study_id uuid NOT NULL, direction character varying(20) NOT NULL, + center_id uuid, site_name character varying(255) NOT NULL, - drug_desc text NOT NULL, ship_date date, + receive_date date, + quantity integer, + batch_no character varying(100), carrier character varying(100), tracking_no character varying(100), - from_party character varying(255), - to_party character varying(255), status character varying(30) NOT NULL, remark text, created_by uuid, created_at timestamp with time zone NOT NULL DEFAULT now(), updated_at timestamp with time zone NOT NULL DEFAULT now(), CONSTRAINT fk_drug_shipments_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_drug_shipments_center_id FOREIGN KEY (center_id) REFERENCES public.sites(id) ON DELETE RESTRICT, CONSTRAINT fk_drug_shipments_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); @@ -495,6 +497,7 @@ CREATE INDEX IF NOT EXISTS ix_finance_items_study_id ON public.finance_items (st CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (site_id); CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id); CREATE INDEX IF NOT EXISTS ix_drug_shipments_study_id ON public.drug_shipments (study_id); +CREATE INDEX IF NOT EXISTS ix_drug_shipments_center_id ON public.drug_shipments (center_id); CREATE INDEX IF NOT EXISTS ix_startup_feasibility_study_id ON public.startup_feasibility (study_id); CREATE INDEX IF NOT EXISTS ix_startup_ethics_study_id ON public.startup_ethics (study_id); CREATE INDEX IF NOT EXISTS ix_kickoff_meetings_study_id ON public.kickoff_meetings (study_id); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 1136a72b..8a8c884d 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -47,13 +47,10 @@ {{ TEXT.menu.feeContracts }} {{ TEXT.menu.feeSpecials }} - - - {{ TEXT.menu.drugShipments }} - + + + {{ TEXT.menu.drugShipments }} + {{ TEXT.menu.fileVersionManagement }} diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 908ac142..d44cd693 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -151,8 +151,11 @@ export const TEXT = { trainedDate: "培训日期", authorizedDate: "授权日期", direction: "类型", - trackingNo: "运单号", - shipDate: "日期", + trackingNo: "单号", + shipDate: "寄出/回收日期", + receiveDate: "接收日期", + quantity: "数量", + batchNo: "批号", drugDesc: "物品描述", carrier: "快递公司", fromParty: "发出方", @@ -245,7 +248,7 @@ export const TEXT = { feeContracts: "合同管理", feeSpecials: "特殊费用管理", drug: "药品管理", - drugShipments: "运输/流向", + drugShipments: "药品流向管理", fileVersionManagement: "文件版本管理", startupFeasibilityEthics: "立项与伦理", startupMeetingAuth: "启动与授权", @@ -419,7 +422,7 @@ export const TEXT = { uploadHint: "保存后可上传凭证/发票/其他附件", }, drugShipments: { - title: "药品运输/流向", + title: "药品流向管理", subtitle: "登记寄送与回收信息", recordLabel: "运输记录", empty: "暂无运输记录", @@ -429,7 +432,7 @@ export const TEXT = { formSubtitle: "记录寄送/回收流向", uploadHint: "保存后可上传交接材料", detailTitle: "运输记录详情", - detailSubtitle: "查看运输/流向信息与附件", + detailSubtitle: "查看药品流向信息与附件", }, fileVersionManagement: { title: "文件版本管理", diff --git a/frontend/src/views/drug/ShipmentDetail.vue b/frontend/src/views/drug/ShipmentDetail.vue index 43bfd07a..dd06be3f 100644 --- a/frontend/src/views/drug/ShipmentDetail.vue +++ b/frontend/src/views/drug/ShipmentDetail.vue @@ -11,20 +11,38 @@ - - - {{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }} - {{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }} - {{ detail.site_name || TEXT.common.fallback }} - {{ displayDate(detail.ship_date) }} - {{ detail.carrier || TEXT.common.fallback }} - {{ detail.tracking_no || TEXT.common.fallback }} - {{ detail.from_party || TEXT.common.fallback }} - {{ detail.to_party || TEXT.common.fallback }} - {{ detail.drug_desc || TEXT.common.fallback }} - {{ detail.remark || TEXT.common.fallback }} - - +
+ + + + {{ detail.site_name || TEXT.common.fallback }} + + + {{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }} + + + + + + + + {{ detail.tracking_no || TEXT.common.fallback }} + {{ detail.carrier || TEXT.common.fallback }} + {{ displayDate(detail.ship_date) }} + {{ displayDate(detail.receive_date) }} + {{ detail.quantity ?? TEXT.common.fallback }} + {{ detail.batch_no || TEXT.common.fallback }} + {{ detail.remark || TEXT.common.fallback }} + + +
({ status: "", site_name: "", ship_date: "", + receive_date: "", + quantity: null, + batch_no: "", carrier: "", tracking_no: "", - from_party: "", - to_party: "", - drug_desc: "", remark: "", }); @@ -109,6 +127,18 @@ onMounted(load); color: var(--ctms-text-secondary); } +.content { + display: flex; + flex-direction: column; + gap: 16px; +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + .actions { display: flex; gap: 8px; diff --git a/frontend/src/views/drug/ShipmentForm.vue b/frontend/src/views/drug/ShipmentForm.vue index 9cdd9267..fa98835d 100644 --- a/frontend/src/views/drug/ShipmentForm.vue +++ b/frontend/src/views/drug/ShipmentForm.vue @@ -9,49 +9,85 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +

{{ TEXT.common.labels.basicInfo }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

{{ TEXT.modules.drugShipments.recordLabel }}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - {{ TEXT.common.actions.save }} - {{ TEXT.common.actions.cancel }} +
+ {{ TEXT.common.actions.save }} + {{ TEXT.common.actions.cancel }} +
@@ -74,6 +110,7 @@ import { useRoute, useRouter } from "vue-router"; import { ElMessage } from "element-plus"; import { useStudyStore } from "../../store/study"; import { createDrugShipment, getDrugShipment, updateDrugShipment } from "../../api/drugShipments"; +import { fetchSites } from "../../api/sites"; import AttachmentList from "../../components/attachments/AttachmentList.vue"; import StateEmpty from "../../components/StateEmpty.vue"; import { TEXT } from "../../locales"; @@ -82,37 +119,63 @@ const route = useRoute(); const router = useRouter(); const study = useStudyStore(); const saving = ref(false); +const formRef = ref(); +const sites = ref([]); const shipmentId = computed(() => route.params.shipmentId as string | undefined); const isEdit = computed(() => !!shipmentId.value); const studyId = computed(() => study.currentStudy?.id || ""); const form = reactive({ + center_id: "", direction: "SEND", - site_name: "", - drug_desc: "", ship_date: "", + receive_date: "", + quantity: null as number | null, + batch_no: "", carrier: "", tracking_no: "", - from_party: "", - to_party: "", status: "PENDING", remark: "", }); +const rules = { + center_id: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }], + direction: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }], + ship_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }], + receive_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }], + quantity: [{ required: true, type: "number", message: TEXT.common.messages.required, trigger: "change" }], + batch_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }], + carrier: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }], + tracking_no: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }], + status: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }], + remark: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }], +}; + +const loadSites = async () => { + const studyId = study.currentStudy?.id; + if (!studyId) return; + try { + const { data } = await fetchSites(studyId, { limit: 500 }); + sites.value = Array.isArray(data) ? data : data.items || []; + } catch { + sites.value = []; + } +}; + const load = async () => { if (!isEdit.value || !studyId.value || !shipmentId.value) return; try { const { data } = await getDrugShipment(studyId.value, shipmentId.value); Object.assign(form, { direction: data.direction || "SEND", - site_name: data.site_name || "", - drug_desc: data.drug_desc || "", + center_id: data.center_id || "", ship_date: data.ship_date || "", + receive_date: data.receive_date || "", + quantity: data.quantity ?? null, + batch_no: data.batch_no || "", carrier: data.carrier || "", tracking_no: data.tracking_no || "", - from_party: data.from_party || "", - to_party: data.to_party || "", status: data.status || "PENDING", remark: data.remark || "", }); @@ -123,17 +186,19 @@ const load = async () => { const submit = async () => { if (!studyId.value) return; + const valid = await formRef.value?.validate().catch(() => false); + if (!valid) return; saving.value = true; try { const payload = { + center_id: form.center_id, direction: form.direction, - site_name: form.site_name, - drug_desc: form.drug_desc, ship_date: form.ship_date || null, + receive_date: form.receive_date || null, + quantity: form.quantity, + batch_no: form.batch_no, carrier: form.carrier || null, tracking_no: form.tracking_no || null, - from_party: form.from_party || null, - to_party: form.to_party || null, status: form.status, remark: form.remark || null, }; @@ -155,7 +220,10 @@ const submit = async () => { const goBack = () => router.push("/drug/shipments"); -onMounted(load); +onMounted(async () => { + await loadSites(); + load(); +}); diff --git a/frontend/src/views/fees/ContractFees.vue b/frontend/src/views/fees/ContractFees.vue index d66b8291..cef9e52c 100644 --- a/frontend/src/views/fees/ContractFees.vue +++ b/frontend/src/views/fees/ContractFees.vue @@ -157,7 +157,7 @@ {{ scope.row.remark || TEXT.common.fallback }} - + - +