抽屉化物资与启动授权流程

1、将药物发货新建和编辑从独立页面迁移到抽屉,详情页支持权限控制和停用中心只读状态。

2、补充药物发货状态字段校验,统一 PENDING、IN_TRANSIT、SIGNED、EXCEPTION 状态及演示数据。

3、为物资设备增加详情页和编辑抽屉,复用通用附件上传并按项目权限控制操作入口。

4、将启动会和培训授权编辑迁移到抽屉,详情页按中心过滤培训记录并移除旧独立编辑路由。
This commit is contained in:
Cheng Zhou
2026-06-04 11:08:43 +08:00
parent 720c98765f
commit 6a2e43f829
31 changed files with 3773 additions and 1009 deletions
+20 -1
View File
@@ -8,7 +8,12 @@ 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
from app.schemas.drug_shipment import (
DrugShipmentCreate,
DrugShipmentRead,
DrugShipmentUpdate,
validate_drug_shipment_required_fields,
)
router = APIRouter()
@@ -143,6 +148,20 @@ async def update_shipment(
shipment_in = shipment_in.model_copy(update={"site_name": site.name})
else:
await _ensure_center_active(db, study_id, shipment.center_id)
update_data = shipment_in.model_dump(exclude_unset=True)
try:
validate_drug_shipment_required_fields(
update_data.get("status", shipment.status),
update_data.get("ship_date", shipment.ship_date),
update_data.get("receive_date", shipment.receive_date),
update_data.get("quantity", shipment.quantity),
update_data.get("batch_no", shipment.batch_no),
update_data.get("carrier", shipment.carrier),
update_data.get("tracking_no", shipment.tracking_no),
update_data.get("remark", shipment.remark),
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
shipment = await shipment_crud.update_shipment(db, shipment, shipment_in)
await audit_crud.log_action(
db,
+2 -1
View File
@@ -468,13 +468,14 @@ async def list_training_authorizations(
study_id: uuid.UUID,
skip: int = 0,
limit: int = 200,
site_name: str | None = None,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[TrainingAuthorizationRead]:
await _ensure_study_exists(db, study_id)
cra_scope = await get_cra_site_scope(db, study_id, current_user)
site_names = cra_scope[1] if cra_scope else None
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names)
items = await startup_crud.list_training_authorizations(db, study_id, skip=skip, limit=limit, site_names=site_names, site_name=site_name)
return [TrainingAuthorizationRead.model_validate(item) for item in items]
+3
View File
@@ -243,6 +243,7 @@ async def list_training_authorizations(
skip: int = 0,
limit: int = 200,
site_names: set[str] | None = None,
site_name: str | None = None,
) -> Sequence[TrainingAuthorization]:
if site_names is not None and not site_names:
return []
@@ -255,6 +256,8 @@ async def list_training_authorizations(
)
if site_names is not None:
stmt = stmt.where(TrainingAuthorization.site_name.in_(site_names))
if site_name:
stmt = stmt.where(TrainingAuthorization.site_name == site_name)
result = await db.execute(stmt)
return result.scalars().all()
+74 -8
View File
@@ -1,22 +1,88 @@
from __future__ import annotations
import uuid
from datetime import date, datetime
from typing import Optional
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, model_validator
SHIPMENT_STATUSES = {"PENDING", "IN_TRANSIT", "SIGNED", "EXCEPTION"}
COMPLETED_SHIPMENT_STATUS = "SIGNED"
EXCEPTION_SHIPMENT_STATUS = "EXCEPTION"
PENDING_SHIPMENT_STATUS = "PENDING"
def shipment_requires_execution_details(status: str | None) -> bool:
return status != PENDING_SHIPMENT_STATUS
def shipment_requires_receive_date(status: str | None) -> bool:
return status == COMPLETED_SHIPMENT_STATUS
def shipment_requires_remark(status: str | None) -> bool:
return status == EXCEPTION_SHIPMENT_STATUS
def validate_drug_shipment_required_fields(
status: str | None,
ship_date: date | None,
receive_date: date | None,
quantity: int | None,
batch_no: str | None,
carrier: str | None,
tracking_no: str | None,
remark: str | None,
) -> None:
if status not in SHIPMENT_STATUSES:
raise ValueError("无效状态")
if shipment_requires_execution_details(status):
missing_execution_fields = [
label
for label, value in (
("寄出/回收日期", ship_date),
("数量", quantity),
("批号", batch_no),
("快递公司", carrier),
("单号", tracking_no),
)
if value is None or (isinstance(value, str) and not value.strip())
]
if missing_execution_fields:
raise ValueError(f"发运信息缺失:{', '.join(missing_execution_fields)}")
if shipment_requires_receive_date(status) and receive_date is None:
raise ValueError("已签收时必须填写接收日期")
if shipment_requires_remark(status) and not (remark or "").strip():
raise ValueError("异常状态必须填写备注")
class DrugShipmentCreate(BaseModel):
direction: str
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
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
status: str
remark: str
remark: Optional[str] = None
@model_validator(mode="after")
def validate_status_required_fields(self):
validate_drug_shipment_required_fields(
self.status,
self.ship_date,
self.receive_date,
self.quantity,
self.batch_no,
self.carrier,
self.tracking_no,
self.remark,
)
return self
class DrugShipmentUpdate(BaseModel):
@@ -0,0 +1,81 @@
import uuid
from datetime import date
import pytest
from pydantic import ValidationError
from app.schemas.drug_shipment import DrugShipmentCreate
def shipment_payload(**overrides):
payload = {
"direction": "SEND",
"center_id": uuid.uuid4(),
"ship_date": date(2026, 6, 4),
"receive_date": None,
"quantity": 1,
"batch_no": "DP-001",
"carrier": "顺丰",
"tracking_no": "SF100003",
"status": "IN_TRANSIT",
"remark": None,
}
payload.update(overrides)
return payload
def test_create_allows_pending_shipment_execution_fields_to_be_empty():
shipment = DrugShipmentCreate.model_validate(
shipment_payload(
status="PENDING",
ship_date=None,
quantity=None,
batch_no=None,
carrier=None,
tracking_no=None,
receive_date=None,
remark=None,
)
)
assert shipment.ship_date is None
assert shipment.quantity is None
assert shipment.batch_no is None
assert shipment.carrier is None
assert shipment.tracking_no is None
@pytest.mark.parametrize("field_name", ["ship_date", "quantity", "batch_no", "carrier", "tracking_no"])
def test_create_requires_shipment_execution_fields_after_pending(field_name):
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="IN_TRANSIT", **{field_name: None}))
assert "发运信息" in str(exc_info.value)
def test_create_allows_pending_receipt_fields_to_be_empty():
shipment = DrugShipmentCreate.model_validate(shipment_payload())
assert shipment.receive_date is None
assert shipment.remark is None
def test_create_requires_receive_date_when_signed():
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="SIGNED", receive_date=None))
assert "接收日期" in str(exc_info.value)
def test_create_rejects_removed_returned_status():
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="RETURNED", receive_date=date(2026, 6, 5)))
assert "无效状态" in str(exc_info.value)
def test_create_requires_remark_when_exceptional():
with pytest.raises(ValidationError) as exc_info:
DrugShipmentCreate.model_validate(shipment_payload(status="EXCEPTION", remark=""))
assert "备注" in str(exc_info.value)