药品流向管理内容优化-初步
This commit is contained in:
@@ -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
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-3
@@ -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);
|
||||
|
||||
@@ -47,13 +47,10 @@
|
||||
<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-sub-menu>
|
||||
<el-sub-menu index="drug">
|
||||
<template #title>
|
||||
<el-menu-item index="/drug/shipments">
|
||||
<el-icon><Box /></el-icon>
|
||||
<span>{{ TEXT.menu.drug }}</span>
|
||||
</template>
|
||||
<el-menu-item index="/drug/shipments">{{ TEXT.menu.drugShipments }}</el-menu-item>
|
||||
</el-sub-menu>
|
||||
<span>{{ TEXT.menu.drugShipments }}</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/file-versions">
|
||||
<el-icon><Files /></el-icon>
|
||||
<span>{{ TEXT.menu.fileVersionManagement }}</span>
|
||||
|
||||
@@ -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: "文件版本管理",
|
||||
|
||||
@@ -11,20 +11,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading">
|
||||
<div class="content">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>{{ TEXT.common.labels.basicInfo }}</span>
|
||||
<el-tag :type="detail.status === 'PENDING' ? 'info' : detail.status === 'SIGNED' ? 'success' : 'primary'">
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="TEXT.common.fields.direction">{{ displayEnum(TEXT.enums.shipmentDirection, detail.direction) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.status">{{ displayEnum(TEXT.enums.shipmentStatus, detail.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || 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.carrier">{{ detail.carrier || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.direction">
|
||||
<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.fromParty">{{ detail.from_party || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.toParty">{{ detail.to_party || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.common.fields.drugDesc" :span="2">{{ detail.drug_desc || 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
|
||||
v-if="shipmentId"
|
||||
@@ -57,11 +75,11 @@ const detail = reactive<any>({
|
||||
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;
|
||||
|
||||
@@ -9,36 +9,32 @@
|
||||
</div>
|
||||
|
||||
<el-card>
|
||||
<el-form :model="form" label-width="120px">
|
||||
<el-form-item :label="TEXT.common.fields.direction" required>
|
||||
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" label-position="top">
|
||||
<h3 class="section-title">{{ TEXT.common.labels.basicInfo }}</h3>
|
||||
<el-row :gutter="24">
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.site" prop="center_id">
|
||||
<el-select v-model="form.center_id" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
|
||||
<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-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.direction" prop="direction">
|
||||
<el-select v-model="form.direction" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
|
||||
<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 :label="TEXT.common.fields.site" required>
|
||||
<el-input v-model="form.site_name" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.site" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.drugDesc" required>
|
||||
<el-input v-model="form.drug_desc" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.shipDate">
|
||||
<el-date-picker v-model="form.ship_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.carrier">
|
||||
<el-input v-model="form.carrier" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.carrier" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.trackingNo">
|
||||
<el-input v-model="form.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.fromParty">
|
||||
<el-input v-model="form.from_party" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.fromParty" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.toParty">
|
||||
<el-input v-model="form.to_party" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.toParty" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.status" required>
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select">
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item :label="TEXT.common.fields.status" prop="status">
|
||||
<el-select v-model="form.status" :placeholder="TEXT.common.placeholders.select" style="width: 100%">
|
||||
<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.SIGNED" value="SIGNED" />
|
||||
@@ -46,12 +42,52 @@
|
||||
<el-option :label="TEXT.enums.shipmentStatus.EXCEPTION" value="EXCEPTION" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.common.fields.remark">
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<h3 class="section-title">{{ TEXT.modules.drugShipments.recordLabel }}</h3>
|
||||
<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-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<div class="actions">
|
||||
<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>
|
||||
</el-card>
|
||||
@@ -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<any[]>([]);
|
||||
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -186,4 +254,18 @@ onMounted(load);
|
||||
.hint-card {
|
||||
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>
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
{{ scope.row.remark || TEXT.common.fallback }}
|
||||
</template>
|
||||
</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">
|
||||
<el-button
|
||||
link
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</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">
|
||||
<el-button
|
||||
link
|
||||
|
||||
@@ -11,13 +11,23 @@
|
||||
<el-card class="filter-card">
|
||||
<el-form :inline="true" :model="filters">
|
||||
<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 :label="TEXT.common.fields.trackingNo">
|
||||
<el-input v-model="filters.tracking_no" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.trackingNo" clearable />
|
||||
<el-form-item :label="TEXT.common.fields.direction">
|
||||
<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 :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.IN_TRANSIT" value="IN_TRANSIT" />
|
||||
<el-option :label="TEXT.enums.shipmentStatus.SIGNED" value="SIGNED" />
|
||||
@@ -33,23 +43,42 @@
|
||||
</el-card>
|
||||
|
||||
<el-card>
|
||||
<el-table :data="items" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="direction" :label="TEXT.common.fields.direction" width="100">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.shipmentDirection, scope.row.direction) }}</template>
|
||||
<el-table :data="items" v-loading="loading" style="width: 100%" @row-click="onRowClick">
|
||||
<el-table-column prop="site_name" :label="TEXT.common.fields.site" min-width="160">
|
||||
<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 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">
|
||||
<template #default="scope">{{ displayDate(scope.row.ship_date) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status" width="120">
|
||||
<template #default="scope">{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}</template>
|
||||
<el-table-column prop="receive_date" :label="TEXT.common.fields.receiveDate" width="140">
|
||||
<template #default="scope">{{ displayDate(scope.row.receive_date) }}</template>
|
||||
</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">
|
||||
<el-button link type="primary" size="small" @click="goDetail(scope.row.id)">{{ TEXT.common.actions.view }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="goEdit(scope.row.id)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button link type="danger" size="small" @click="remove(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
|
||||
<el-tag :type="statusType(scope.row.status)" disable-transitions>
|
||||
{{ displayEnum(TEXT.enums.shipmentStatus, scope.row.status) }}
|
||||
</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>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -64,6 +93,7 @@ import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { listDrugShipments, deleteDrugShipment } from "../../api/drugShipments";
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import { displayDate, displayEnum } from "../../utils/display";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
@@ -72,20 +102,32 @@ const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const sites = ref<any[]>([]);
|
||||
const filters = reactive({
|
||||
site_name: "",
|
||||
tracking_no: "",
|
||||
center_id: "",
|
||||
direction: "",
|
||||
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 studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await listDrugShipments(studyId, {
|
||||
site_name: filters.site_name || undefined,
|
||||
tracking_no: filters.tracking_no || undefined,
|
||||
center_id: filters.center_id || undefined,
|
||||
direction: filters.direction || undefined,
|
||||
status: filters.status || undefined,
|
||||
});
|
||||
items.value = Array.isArray(data) ? data : data.items || [];
|
||||
@@ -97,15 +139,35 @@ const load = async () => {
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.site_name = "";
|
||||
filters.tracking_no = "";
|
||||
filters.center_id = "";
|
||||
filters.direction = "";
|
||||
filters.status = "";
|
||||
load();
|
||||
};
|
||||
|
||||
const goNew = () => router.push("/drug/shipments/new");
|
||||
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 studyId = study.currentStudy?.id;
|
||||
@@ -121,7 +183,10 @@ const remove = async (row: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user