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

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.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 audit as audit_crud
from app.crud import drug_shipment as shipment_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.crud import study as study_crud
from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate from app.schemas.drug_shipment import DrugShipmentCreate, DrugShipmentRead, DrugShipmentUpdate
@@ -32,6 +33,10 @@ async def create_shipment(
current_user=Depends(get_current_user), current_user=Depends(get_current_user),
) -> DrugShipmentRead: ) -> DrugShipmentRead:
await _ensure_study_exists(db, study_id) 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) shipment = await shipment_crud.create_shipment(db, study_id, shipment_in, created_by=current_user.id)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
@@ -53,8 +58,10 @@ async def create_shipment(
) )
async def list_shipments( async def list_shipments(
study_id: uuid.UUID, study_id: uuid.UUID,
center_id: uuid.UUID | None = None,
site_name: str | None = None, site_name: str | None = None,
tracking_no: str | None = None, tracking_no: str | None = None,
direction: str | None = None,
status: str | None = None, status: str | None = None,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
@@ -62,7 +69,15 @@ async def list_shipments(
) -> list[DrugShipmentRead]: ) -> list[DrugShipmentRead]:
await _ensure_study_exists(db, study_id) await _ensure_study_exists(db, study_id)
items = await shipment_crud.list_shipments( 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] 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) shipment = await shipment_crud.get_shipment(db, shipment_id)
if not shipment or shipment.study_id != study_id: if not shipment or shipment.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="运输记录不存在") 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) shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
await audit_crud.log_action( await audit_crud.log_action(
db, db,
+11 -4
View File
@@ -17,13 +17,14 @@ async def create_shipment(
shipment = DrugShipment( shipment = DrugShipment(
study_id=study_id, study_id=study_id,
direction=shipment_in.direction, direction=shipment_in.direction,
site_name=shipment_in.site_name, center_id=shipment_in.center_id,
drug_desc=shipment_in.drug_desc, site_name=shipment_in.site_name or "",
ship_date=shipment_in.ship_date, 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, carrier=shipment_in.carrier,
tracking_no=shipment_in.tracking_no, tracking_no=shipment_in.tracking_no,
from_party=shipment_in.from_party,
to_party=shipment_in.to_party,
status=shipment_in.status, status=shipment_in.status,
remark=shipment_in.remark, remark=shipment_in.remark,
created_by=created_by, created_by=created_by,
@@ -42,17 +43,23 @@ async def get_shipment(db: AsyncSession, shipment_id: uuid.UUID) -> DrugShipment
async def list_shipments( async def list_shipments(
db: AsyncSession, db: AsyncSession,
study_id: uuid.UUID, study_id: uuid.UUID,
center_id: uuid.UUID | None = None,
site_name: str | None = None, site_name: str | None = None,
tracking_no: str | None = None, tracking_no: str | None = None,
direction: str | None = None,
status: str | None = None, status: str | None = None,
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
) -> Sequence[DrugShipment]: ) -> Sequence[DrugShipment]:
stmt = select(DrugShipment).where(DrugShipment.study_id == study_id) stmt = select(DrugShipment).where(DrugShipment.study_id == study_id)
if center_id:
stmt = stmt.where(DrugShipment.center_id == center_id)
if site_name: if site_name:
stmt = stmt.where(DrugShipment.site_name.ilike(f"%{site_name}%")) stmt = stmt.where(DrugShipment.site_name.ilike(f"%{site_name}%"))
if tracking_no: if tracking_no:
stmt = stmt.where(DrugShipment.tracking_no.ilike(f"%{tracking_no}%")) stmt = stmt.where(DrugShipment.tracking_no.ilike(f"%{tracking_no}%"))
if direction:
stmt = stmt.where(DrugShipment.direction == direction)
if status: if status:
stmt = stmt.where(DrugShipment.status == status) stmt = stmt.where(DrugShipment.status == status)
stmt = stmt.order_by(DrugShipment.created_at.desc()).offset(skip).limit(limit) stmt = stmt.order_by(DrugShipment.created_at.desc()).offset(skip).limit(limit)
+5 -4
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import date, datetime 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.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column 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) 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) 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) 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) 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) 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) carrier: Mapped[str | None] = mapped_column(String(100), nullable=True)
tracking_no: 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) status: Mapped[str] = mapped_column(String(30), nullable=False)
remark: Mapped[str | None] = mapped_column(Text, nullable=True) 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) 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): class DrugShipmentCreate(BaseModel):
direction: str direction: str
site_name: str center_id: uuid.UUID
drug_desc: str site_name: Optional[str] = None
ship_date: Optional[date] = None ship_date: date
carrier: Optional[str] = None receive_date: date
tracking_no: Optional[str] = None quantity: int
from_party: Optional[str] = None batch_no: str
to_party: Optional[str] = None carrier: str
tracking_no: str
status: str status: str
remark: Optional[str] = None remark: str
class DrugShipmentUpdate(BaseModel): class DrugShipmentUpdate(BaseModel):
direction: Optional[str] = None direction: Optional[str] = None
center_id: Optional[uuid.UUID] = None
site_name: Optional[str] = None site_name: Optional[str] = None
drug_desc: Optional[str] = None
ship_date: Optional[date] = None ship_date: Optional[date] = None
receive_date: Optional[date] = None
quantity: Optional[int] = None
batch_no: Optional[str] = None
carrier: Optional[str] = None carrier: Optional[str] = None
tracking_no: Optional[str] = None tracking_no: Optional[str] = None
from_party: Optional[str] = None
to_party: Optional[str] = None
status: Optional[str] = None status: Optional[str] = None
remark: Optional[str] = None remark: Optional[str] = None
@@ -35,13 +37,14 @@ class DrugShipmentRead(BaseModel):
id: uuid.UUID id: uuid.UUID
study_id: uuid.UUID study_id: uuid.UUID
direction: str direction: str
center_id: Optional[uuid.UUID]
site_name: str site_name: str
drug_desc: str
ship_date: Optional[date] ship_date: Optional[date]
receive_date: Optional[date]
quantity: Optional[int]
batch_no: Optional[str]
carrier: Optional[str] carrier: Optional[str]
tracking_no: Optional[str] tracking_no: Optional[str]
from_party: Optional[str]
to_party: Optional[str]
status: str status: str
remark: Optional[str] remark: Optional[str]
created_by: Optional[uuid.UUID] created_by: Optional[uuid.UUID]
-1
View File
@@ -2,7 +2,6 @@ fastapi==0.104.1
uvicorn[standard]==0.24.0.post1 uvicorn[standard]==0.24.0.post1
sqlalchemy==2.0.23 sqlalchemy==2.0.23
asyncpg==0.29.0 asyncpg==0.29.0
alembic==1.13.1
pydantic-settings==2.1.0 pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
+6 -3
View File
@@ -300,19 +300,21 @@ CREATE TABLE IF NOT EXISTS public.drug_shipments (
id uuid PRIMARY KEY, id uuid PRIMARY KEY,
study_id uuid NOT NULL, study_id uuid NOT NULL,
direction character varying(20) NOT NULL, direction character varying(20) NOT NULL,
center_id uuid,
site_name character varying(255) NOT NULL, site_name character varying(255) NOT NULL,
drug_desc text NOT NULL,
ship_date date, ship_date date,
receive_date date,
quantity integer,
batch_no character varying(100),
carrier character varying(100), carrier character varying(100),
tracking_no character varying(100), tracking_no character varying(100),
from_party character varying(255),
to_party character varying(255),
status character varying(30) NOT NULL, status character varying(30) NOT NULL,
remark text, remark text,
created_by uuid, created_by uuid,
created_at timestamp with time zone NOT NULL DEFAULT now(), created_at timestamp with time zone NOT NULL DEFAULT now(),
updated_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_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 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_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_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_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_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_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); CREATE INDEX IF NOT EXISTS ix_kickoff_meetings_study_id ON public.kickoff_meetings (study_id);
+4 -7
View File
@@ -47,13 +47,10 @@
<el-menu-item index="/fees/contracts">{{ TEXT.menu.feeContracts }}</el-menu-item> <el-menu-item index="/fees/contracts">{{ TEXT.menu.feeContracts }}</el-menu-item>
<el-menu-item index="/fees/special">{{ TEXT.menu.feeSpecials }}</el-menu-item> <el-menu-item index="/fees/special">{{ TEXT.menu.feeSpecials }}</el-menu-item>
</el-sub-menu> </el-sub-menu>
<el-sub-menu index="drug"> <el-menu-item index="/drug/shipments">
<template #title> <el-icon><Box /></el-icon>
<el-icon><Box /></el-icon> <span>{{ TEXT.menu.drugShipments }}</span>
<span>{{ TEXT.menu.drug }}</span> </el-menu-item>
</template>
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
</el-sub-menu>
<el-menu-item index="/file-versions"> <el-menu-item index="/file-versions">
<el-icon><Files /></el-icon> <el-icon><Files /></el-icon>
<span>{{ TEXT.menu.fileVersionManagement }}</span> <span>{{ TEXT.menu.fileVersionManagement }}</span>
+8 -5
View File
@@ -151,8 +151,11 @@ export const TEXT = {
trainedDate: "培训日期", trainedDate: "培训日期",
authorizedDate: "授权日期", authorizedDate: "授权日期",
direction: "类型", direction: "类型",
trackingNo: "单号", trackingNo: "单号",
shipDate: "日期", shipDate: "寄出/回收日期",
receiveDate: "接收日期",
quantity: "数量",
batchNo: "批号",
drugDesc: "物品描述", drugDesc: "物品描述",
carrier: "快递公司", carrier: "快递公司",
fromParty: "发出方", fromParty: "发出方",
@@ -245,7 +248,7 @@ export const TEXT = {
feeContracts: "合同管理", feeContracts: "合同管理",
feeSpecials: "特殊费用管理", feeSpecials: "特殊费用管理",
drug: "药品管理", drug: "药品管理",
drugShipments: "运输/流向", drugShipments: "药品流向管理",
fileVersionManagement: "文件版本管理", fileVersionManagement: "文件版本管理",
startupFeasibilityEthics: "立项与伦理", startupFeasibilityEthics: "立项与伦理",
startupMeetingAuth: "启动与授权", startupMeetingAuth: "启动与授权",
@@ -419,7 +422,7 @@ export const TEXT = {
uploadHint: "保存后可上传凭证/发票/其他附件", uploadHint: "保存后可上传凭证/发票/其他附件",
}, },
drugShipments: { drugShipments: {
title: "药品运输/流向", title: "药品流向管理",
subtitle: "登记寄送与回收信息", subtitle: "登记寄送与回收信息",
recordLabel: "运输记录", recordLabel: "运输记录",
empty: "暂无运输记录", empty: "暂无运输记录",
@@ -429,7 +432,7 @@ export const TEXT = {
formSubtitle: "记录寄送/回收流向", formSubtitle: "记录寄送/回收流向",
uploadHint: "保存后可上传交接材料", uploadHint: "保存后可上传交接材料",
detailTitle: "运输记录详情", detailTitle: "运输记录详情",
detailSubtitle: "查看运输/流向信息与附件", detailSubtitle: "查看药品流向信息与附件",
}, },
fileVersionManagement: { fileVersionManagement: {
title: "文件版本管理", title: "文件版本管理",
+47 -17
View File
@@ -11,20 +11,38 @@
</div> </div>
</div> </div>
<el-card v-loading="loading"> <div class="content">
<el-descriptions :column="2" border> <el-card>
<el-descriptions-item :label="TEXT.common.fields.direction">{{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}</el-descriptions-item> <template #header>
<el-descriptions-item :label="TEXT.common.fields.status">{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}</el-descriptions-item> <div class="card-header">
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item> <span>{{ TEXT.common.labels.basicInfo }}</span>
<el-descriptions-item :label="TEXT.common.fields.shipDate">{{ displayDate(detail.ship_date) }}</el-descriptions-item> <el-tag :type="detail.status === 'PENDING' ? 'info' : detail.status === 'SIGNED' ? 'success' : 'primary'">
<el-descriptions-item :label="TEXT.common.fields.carrier">{{ detail.carrier || TEXT.common.fallback }}</el-descriptions-item> {{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
<el-descriptions-item :label="TEXT.common.fields.trackingNo">{{ detail.tracking_no || TEXT.common.fallback }}</el-descriptions-item> </el-tag>
<el-descriptions-item :label="TEXT.common.fields.fromParty">{{ detail.from_party || TEXT.common.fallback }}</el-descriptions-item> </div>
<el-descriptions-item :label="TEXT.common.fields.toParty">{{ detail.to_party || TEXT.common.fallback }}</el-descriptions-item> </template>
<el-descriptions-item :label="TEXT.common.fields.drugDesc" :span="2">{{ detail.drug_desc || TEXT.common.fallback }}</el-descriptions-item> <el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item> <el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions> <el-descriptions-item :label="TEXT.common.fields.direction">
</el-card> <el-tag :type="detail.direction === 'SEND' ? 'primary' : 'warning'" size="small">
{{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card :header="TEXT.modules.drugShipments.recordLabel">
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.trackingNo">{{ detail.tracking_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.carrier">{{ detail.carrier || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.shipDate">{{ displayDate(detail.ship_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.receiveDate">{{ displayDate(detail.receive_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.quantity">{{ detail.quantity ?? TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.batchNo">{{ detail.batch_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<AttachmentList <AttachmentList
v-if="shipmentId" v-if="shipmentId"
@@ -57,11 +75,11 @@ const detail = reactive<any>({
status: "", status: "",
site_name: "", site_name: "",
ship_date: "", ship_date: "",
receive_date: "",
quantity: null,
batch_no: "",
carrier: "", carrier: "",
tracking_no: "", tracking_no: "",
from_party: "",
to_party: "",
drug_desc: "",
remark: "", remark: "",
}); });
@@ -109,6 +127,18 @@ onMounted(load);
color: var(--ctms-text-secondary); 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 { .actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
+135 -53
View File
@@ -9,49 +9,85 @@
</div> </div>
<el-card> <el-card>
<el-form :model="form" label-width="120px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
<el-form-item :label="TEXT.common.fields.direction" required> <h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select"> <el-row :gutter="24">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" /> <el-col :span="12">
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" /> <el-form-item :label="TEXT.common.fields.site" prop="center_id">
</el-select> <el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
</el-form-item> <el-option
<el-form-item :label="TEXT.common.fields.site" required> v-for="site in sites"
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" /> :key="site.id"
</el-form-item> :label="site.name || TEXT.common.fallback"
<el-form-item :label="TEXT.common.fields.drugDesc" required> :value="site.id"
<el-input v-model="form.drug_desc" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" /> />
</el-form-item> </el-select>
<el-form-item :label="TEXT.common.fields.shipDate"> </el-form-item>
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" /> </el-col>
</el-form-item> <el-col :span="12">
<el-form-item :label="TEXT.common.fields.carrier"> <el-form-item :label="TEXT.common.fields.direction" prop="direction">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" /> <el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
</el-form-item> <el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-form-item :label="TEXT.common.fields.trackingNo"> <el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" /> </el-select>
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.fromParty"> </el-col>
<el-input v-model="form.from_party" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.fromParty" /> <el-col :span="12">
</el-form-item> <el-form-item :label="TEXT.common.fields.status" prop="status">
<el-form-item :label="TEXT.common.fields.toParty"> <el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
<el-input v-model="form.to_party" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.toParty" /> <el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
</el-form-item> <el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-form-item :label="TEXT.common.fields.status" required> <el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select"> <el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" />
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" /> <el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" /> </el-select>
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" /> </el-form-item>
<el-option :label="TEXT.enums.shipmentStatus.RETURNED" value="RETURNED" /> </el-col>
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" /> </el-row>
</el-select>
</el-form-item> <h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
<el-form-item :label="TEXT.common.fields.remark"> <el-row :gutter="24">
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.shipDate" prop="ship_date">
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.receiveDate" prop="receive_date">
<el-date-picker v-model="form.receive_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.quantity" prop="quantity">
<el-input-number v-model="form.quantity" :min="0" :controls="false" :placeholder="TEXT.common.placeholders.input" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.batchNo" prop="batch_no">
<el-input v-model="form.batch_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.batchNo" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.carrier" prop="carrier">
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="TEXT.common.fields.trackingNo" prop="tracking_no">
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" />
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="TEXT.common.fields.remark" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" /> <el-input v-model="form.remark" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button> <div class="actions">
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button> <el-button type="primary" :loading="saving" @click="submit">{{ TEXT.common.actions.save }}</el-button>
<el-button @click="goBack">{{ TEXT.common.actions.cancel }}</el-button>
</div>
</el-form-item> </el-form-item>
</el-form> </el-form>
</el-card> </el-card>
@@ -74,6 +110,7 @@ import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { createDrugShipment, getDrugShipment, updateDrugShipment } from "../../api/drugShipments"; import { createDrugShipment, getDrugShipment, updateDrugShipment } from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
import AttachmentList from "../../components/attachments/AttachmentList.vue"; import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -82,37 +119,63 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const saving = ref(false); const saving = ref(false);
const formRef = ref();
const sites = ref<any[]>([]);
const shipmentId = computed(() => route.params.shipmentId as string | undefined); const shipmentId = computed(() => route.params.shipmentId as string | undefined);
const isEdit = computed(() => !!shipmentId.value); const isEdit = computed(() => !!shipmentId.value);
const studyId = computed(() => study.currentStudy?.id || ""); const studyId = computed(() => study.currentStudy?.id || "");
const form = reactive({ const form = reactive({
center_id: "",
direction: "SEND", direction: "SEND",
site_name: "",
drug_desc: "",
ship_date: "", ship_date: "",
receive_date: "",
quantity: null as number | null,
batch_no: "",
carrier: "", carrier: "",
tracking_no: "", tracking_no: "",
from_party: "",
to_party: "",
status: "PENDING", status: "PENDING",
remark: "", 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 () => { const load = async () => {
if (!isEdit.value || !studyId.value || !shipmentId.value) return; if (!isEdit.value || !studyId.value || !shipmentId.value) return;
try { try {
const { data } = await getDrugShipment(studyId.value, shipmentId.value); const { data } = await getDrugShipment(studyId.value, shipmentId.value);
Object.assign(form, { Object.assign(form, {
direction: data.direction || "SEND", direction: data.direction || "SEND",
site_name: data.site_name || "", center_id: data.center_id || "",
drug_desc: data.drug_desc || "",
ship_date: data.ship_date || "", ship_date: data.ship_date || "",
receive_date: data.receive_date || "",
quantity: data.quantity ?? null,
batch_no: data.batch_no || "",
carrier: data.carrier || "", carrier: data.carrier || "",
tracking_no: data.tracking_no || "", tracking_no: data.tracking_no || "",
from_party: data.from_party || "",
to_party: data.to_party || "",
status: data.status || "PENDING", status: data.status || "PENDING",
remark: data.remark || "", remark: data.remark || "",
}); });
@@ -123,17 +186,19 @@ const load = async () => {
const submit = async () => { const submit = async () => {
if (!studyId.value) return; if (!studyId.value) return;
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
saving.value = true; saving.value = true;
try { try {
const payload = { const payload = {
center_id: form.center_id,
direction: form.direction, direction: form.direction,
site_name: form.site_name,
drug_desc: form.drug_desc,
ship_date: form.ship_date || null, ship_date: form.ship_date || null,
receive_date: form.receive_date || null,
quantity: form.quantity,
batch_no: form.batch_no,
carrier: form.carrier || null, carrier: form.carrier || null,
tracking_no: form.tracking_no || null, tracking_no: form.tracking_no || null,
from_party: form.from_party || null,
to_party: form.to_party || null,
status: form.status, status: form.status,
remark: form.remark || null, remark: form.remark || null,
}; };
@@ -155,7 +220,10 @@ const submit = async () => {
const goBack = () => router.push("/drug/shipments"); const goBack = () => router.push("/drug/shipments");
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>
@@ -186,4 +254,18 @@ onMounted(load);
.hint-card { .hint-card {
padding: 12px; padding: 12px;
} }
.section-title {
margin: 0 0 24px;
font-size: 16px;
font-weight: 600;
color: var(--ctms-text-primary);
border-left: 4px solid var(--ctms-primary);
padding-left: 12px;
}
.actions {
display: flex;
gap: 12px;
}
</style> </style>
+1 -1
View File
@@ -157,7 +157,7 @@
{{ scope.row.remark || TEXT.common.fallback }} {{ scope.row.remark || TEXT.common.fallback }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.actions.delete" width="120"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button <el-button
link link
+1 -1
View File
@@ -155,7 +155,7 @@
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.actions.delete" width="120"> <el-table-column :label="TEXT.common.labels.actions" width="120">
<template #default="scope"> <template #default="scope">
<el-button <el-button
link link
+88 -23
View File
@@ -11,13 +11,23 @@
<el-card class="filter-card"> <el-card class="filter-card">
<el-form :inline="true" :model="filters"> <el-form :inline="true" :model="filters">
<el-form-item :label="TEXT.common.fields.site"> <el-form-item :label="TEXT.common.fields.site">
<el-input v-model="filters.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" clearable /> <el-select v-model="filters.center_id" :placeholder="TEXT.common.placeholders.select" clearable style="width: 200px">
<el-option
v-for="site in sites"
:key="site.id"
:label="site.name || TEXT.common.fallback"
:value="site.id"
/>
</el-select>
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.trackingNo"> <el-form-item :label="TEXT.common.fields.direction">
<el-input v-model="filters.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" clearable /> <el-select v-model="filters.direction" :placeholder="TEXT.common.placeholders.select" clearable style="width: 160px">
<el-option :label="TEXT.enums.shipmentDirection.SEND" value="SEND" />
<el-option :label="TEXT.enums.shipmentDirection.RETURN" value="RETURN" />
</el-select>
</el-form-item> </el-form-item>
<el-form-item :label="TEXT.common.fields.status"> <el-form-item :label="TEXT.common.fields.status">
<el-select v-model="filters.status" :placeholder="TEXT.common.placeholders.select" clearable> <el-select v-model="filters.status" :placeholder="TEXT.common.placeholders.select" clearable style="width: 160px">
<el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" /> <el-option :label="TEXT.enums.shipmentStatus.PENDING" value="PENDING" />
<el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" /> <el-option :label="TEXT.enums.shipmentStatus.IN_TRANSIT" value="IN_TRANSIT" />
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" /> <el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
@@ -33,23 +43,42 @@
</el-card> </el-card>
<el-card> <el-card>
<el-table :data="items" v-loading="loading" style="width: 100%"> <el-table :data="items" v-loading="loading" style="width: 100%" @row-click="onRowClick">
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="100"> <el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
<template #default="scope">{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}</template> <template #default="scope">{{ scope.row.site_name || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="100">
<template #default="scope">
<el-tag :type="scope.row.direction === 'SEND' ? 'primary' : 'warning'" disable-transitions>
{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}
</el-tag>
</template>
</el-table-column> </el-table-column>
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160" />
<el-table-column prop="tracking_no" :label="TEXT.common.fields.trackingNo" min-width="160" />
<el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="140"> <el-table-column prop="ship_date" :label="TEXT.common.fields.shipDate" width="140">
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template> <template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120"> <el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="140">
<template #default="scope">{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}</template> <template #default="scope">{{ displayDate(scope.row.receive_date) }}</template>
</el-table-column> </el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="200"> <el-table-column prop="quantity" :label="TEXT.common.fields.quantity" width="120" align="right">
<template #default="scope">{{ typeof scope.row.quantity === "number" ? scope.row.quantity : TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="batch_no" :label="TEXT.common.fields.batchNo" min-width="140">
<template #default="scope">{{ scope.row.batch_no || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
<template #default="scope"> <template #default="scope">
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button> <el-tag :type="statusType(scope.row.status)" disable-transitions>
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button> {{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
<el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button> </el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" :label="TEXT.common.fields.remark" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
</el-table-column>
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
<template #default="scope">
<el-button link type="danger" size="small" @click.stop="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -64,6 +93,7 @@ import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { useStudyStore } from "../../store/study"; import { useStudyStore } from "../../store/study";
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments"; import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display"; import { displayDate, displayEnum } from "../../utils/display";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales"; import { TEXT } from "../../locales";
@@ -72,20 +102,32 @@ const router = useRouter();
const study = useStudyStore(); const study = useStudyStore();
const loading = ref(false); const loading = ref(false);
const items = ref<any[]>([]); const items = ref<any[]>([]);
const sites = ref<any[]>([]);
const filters = reactive({ const filters = reactive({
site_name: "", center_id: "",
tracking_no: "", direction: "",
status: "", status: "",
}); });
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 () => { const load = async () => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
if (!studyId) return; if (!studyId) return;
loading.value = true; loading.value = true;
try { try {
const { data } = await listDrugShipments(studyId, { const { data } = await listDrugShipments(studyId, {
site_name: filters.site_name || undefined, center_id: filters.center_id || undefined,
tracking_no: filters.tracking_no || undefined, direction: filters.direction || undefined,
status: filters.status || undefined, status: filters.status || undefined,
}); });
items.value = Array.isArray(data) ? data : data.items || []; items.value = Array.isArray(data) ? data : data.items || [];
@@ -97,15 +139,35 @@ const load = async () => {
}; };
const resetFilters = () => { const resetFilters = () => {
filters.site_name = ""; filters.center_id = "";
filters.tracking_no = ""; filters.direction = "";
filters.status = ""; filters.status = "";
load(); load();
}; };
const goNew = () => router.push("/drug/shipments/new"); const goNew = () => router.push("/drug/shipments/new");
const goDetail = (id: string) => router.push(`/drug/shipments/${id}`); const goDetail = (id: string) => router.push(`/drug/shipments/${id}`);
const goEdit = (id: string) => router.push(`/drug/shipments/${id}/edit`); const onRowClick = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const statusType = (status: string) => {
switch (status) {
case "PENDING":
return "info";
case "IN_TRANSIT":
return "primary";
case "SIGNED":
return "success";
case "RETURNED":
return "warning";
case "EXCEPTION":
return "danger";
default:
return "info";
}
};
const remove = async (row: any) => { const remove = async (row: any) => {
const studyId = study.currentStudy?.id; const studyId = study.currentStudy?.id;
@@ -121,7 +183,10 @@ const remove = async (row: any) => {
} }
}; };
onMounted(load); onMounted(async () => {
await loadSites();
load();
});
</script> </script>
<style scoped> <style scoped>