feat(backend): expose sample freight APIs and wire protocol saves

Register admin sample-freight routes, enrich protocol sample details with freight, and stop writing legacy transport rows on save.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-08 07:48:41 +08:00
1 parent 6d542a0acf
commit 468a7c5e12
5 files changed
+385 -15

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ from app.api.collection_protocol import router as collection_protocol_router
from app.api.protocol_sample_payment import router as protocol_sample_payment_router
from app.api.protocol_refund_record import router as protocol_refund_record_router
from app.api.presign_order import router as presign_order_router
from app.api.sample_freight import router as sample_freight_router
from app.api.leave import router as leave_router
from app.api.operations import router as operations_router
from app.api.ethnic import router as ethnic_router
@@ -50,6 +51,7 @@ __all__ = [
"protocol_sample_payment_router",
"protocol_refund_record_router",
"presign_order_router",
"sample_freight_router",
"leave_router",
"operations_router",
"ethnic_router",
+210
View File
@@ -0,0 +1,210 @@
"""样本运费批次 API"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.crud import sample_freight as freight_svc
from app.models.models import SysUser
router = APIRouter(prefix="/web/admin", tags=["样本运费批次"])
def _get_tenant_no(db: Session, user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失,无法确定租户")
sys_user = db.query(SysUser).filter(SysUser.id == user_id).first()
if not sys_user or not sys_user.tenant_no:
raise PermissionError("用户租户信息缺失")
return sys_user.tenant_no
def _get_current_user_id(user: dict) -> str:
user_id = user.get("user_id")
if not user_id:
raise PermissionError("用户身份信息缺失")
return str(user_id)
class SampleFreightItemBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
sample_source: str = Field(..., alias="sampleSource")
sample_id: str = Field(..., alias="sampleId")
protocol_id: Optional[str] = Field(None, alias="protocolId")
sample_code: Optional[str] = Field(None, alias="sampleCode")
class SampleFreightCreateBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
transport_mode: str = Field(..., alias="transportMode")
freight_amount: float = Field(..., alias="freightAmount")
items: Optional[List[SampleFreightItemBody]] = None
class SampleFreightUpdateBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
transport_mode: Optional[str] = Field(None, alias="transportMode")
freight_amount: Optional[float] = Field(None, alias="freightAmount")
class SampleFreightItemsBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
items: List[SampleFreightItemBody] = Field(default_factory=list)
@router.get("/sample-freights")
async def list_sample_freights(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
items, total = freight_svc.list_freights(
db,
tenant_no=tenant_no,
page=page,
page_size=pageSize,
keyword=keyword,
)
return success_response(
{"list": items, "total": total, "page": page, "pageSize": pageSize}
)
except PermissionError as e:
return error_response(403, str(e))
@router.get("/sample-freights/{freight_id}")
async def get_sample_freight(
freight_id: str,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
data = freight_svc.get_freight_detail(db, freight_id, tenant_no)
return success_response(data)
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(404, str(e))
@router.post("/sample-freights")
async def create_sample_freight(
body: SampleFreightCreateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
items = [it.model_dump(by_alias=True) for it in (body.items or [])]
data = freight_svc.create_freight(
db,
tenant_no=tenant_no,
user_id=user_id,
transport_mode_raw=body.transport_mode,
freight_amount=body.freight_amount,
items=items,
)
return success_response(data, message="创建成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.put("/sample-freights/{freight_id}")
async def update_sample_freight(
freight_id: str,
body: SampleFreightUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
data = freight_svc.update_freight(
db,
freight_id,
tenant_no=tenant_no,
user_id=user_id,
transport_mode_raw=body.transport_mode,
freight_amount=body.freight_amount,
)
return success_response(data, message="更新成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
msg = str(e)
code = 404 if "不存在" in msg else 400
return error_response(code, msg)
@router.post("/sample-freights/{freight_id}/link")
async def link_sample_freight(
freight_id: str,
body: SampleFreightItemsBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
items = [it.model_dump(by_alias=True) for it in (body.items or [])]
data = freight_svc.link_samples(
db,
freight_id,
tenant_no=tenant_no,
user_id=user_id,
items=items,
)
return success_response(data, message="关联成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
msg = str(e)
code = 404 if "不存在" in msg else 400
return error_response(code, msg)
@router.post("/sample-freights/{freight_id}/unlink")
async def unlink_sample_freight(
freight_id: str,
body: SampleFreightItemsBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
items = [it.model_dump(by_alias=True) for it in (body.items or [])]
data = freight_svc.unlink_samples(
db,
freight_id,
tenant_no=tenant_no,
user_id=user_id,
items=items,
)
return success_response(data, message="已取消关联")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
msg = str(e)
code = 404 if "不存在" in msg else 400
return error_response(code, msg)
+129 -7
View File
@@ -607,12 +607,17 @@ def _transport_to_dict(t: BizProtocolSampleTransport) -> Dict[str, Any]:
def _storage_cert_to_dict(s: BizProtocolStorageCert) -> Dict[str, Any]:
from app.services.sample_info_progress import storage_cert_handled
return {
"id": s.id,
"collectionProtocolId": s.collection_protocol_id,
"makeStatus": s.make_status or "0",
"certImageUrls": s.cert_image_urls or "",
"makeDate": (s.make_date or "").strip() if s.make_date else "",
"handleTime": _fmt_dt(s.handle_time) if getattr(s, "handle_time", None) else "",
"handlerUserId": getattr(s, "handler_user_id", None) or "",
"handled": storage_cert_handled(s),
"tenantNo": s.tenant_no,
"orderNum": s.order_num if s.order_num is not None else 0,
"remark": s.remark or "",
@@ -1348,12 +1353,21 @@ def get_protocol_detail_bundle(
)
unit_name_map = _load_unit_name_map(db, s_rows)
type_name_map = _load_sample_type_name_map(db, s_rows)
from app.crud import sample_freight as sample_freight_svc
freight_by_sample = sample_freight_svc.freight_map_for_samples(
db,
tenant_no=tenant_no,
sample_source=sample_freight_svc.SAMPLE_SOURCE_CELL,
sample_ids=[s.id for s in s_rows],
)
samples_out: List[Dict[str, Any]] = []
for s in s_rows:
item = _sample_to_dict(s)
item["sampleTypeName"] = type_name_map.get(s.sample_type_id or "", "")
item["unitName"] = unit_name_map.get(s.unit_id or "", "")
item["transport"] = transport_by_sample.get(s.id)
item["freight"] = freight_by_sample.get(s.id)
item["sampleReception"] = reception_by_sample.get(s.id)
item["productionReception"] = (reception_by_sample.get(s.id) or {}).get(
"productionReception"
@@ -1365,9 +1379,22 @@ def get_protocol_detail_bundle(
samples_out.append(item)
out["samples"] = samples_out
out["storageCert"] = _storage_cert_to_dict(sc) if sc else None
if out["storageCert"]:
hid = (out["storageCert"].get("handlerUserId") or "").strip()
if hid:
hu = db.query(SysUser).filter(SysUser.id == hid).first()
out["storageCert"]["handlerUserName"] = (hu.nick_name or "") if hu else ""
else:
out["storageCert"]["handlerUserName"] = ""
ft = get_fullmoon_todo(db, protocol_id, tenant_no)
delivery_dt = pc.newborn_delivery_time if pc else None
out["fullmoonTodo"] = _fullmoon_todo_detail_dict(ft, newborn_delivery_time=delivery_dt)
hid2 = (out["fullmoonTodo"].get("handlerUserId") or "").strip()
if hid2:
hu2 = db.query(SysUser).filter(SysUser.id == hid2).first()
out["fullmoonTodo"]["handlerUserName"] = (hu2.nick_name or "") if hu2 else ""
else:
out["fullmoonTodo"]["handlerUserName"] = ""
out["attachments"] = urls_to_attachment_list(p.protocol_attachments, db, tenant_no)
out["consentAttachmentFiles"] = urls_to_attachment_list(p.consent_form_urls, db, tenant_no)
out["healthSurveyAttachmentFiles"] = urls_to_attachment_list(
@@ -1969,7 +1996,7 @@ def _apply_payment_detail_draft_fields(
from app.crud.dict_data import normalize_transfer_type
if d.pay_status != "dfj":
raise ValueError("仅待缴费状态可编辑缴费明细")
raise ValueError("仅已缴费状态可编辑缴费明细")
if "transfer_type" in fields:
d.transfer_type = normalize_transfer_type(
db, value=str(fields.get("transfer_type") or ""), tenant_no=tenant_no
@@ -2024,7 +2051,7 @@ def _apply_submit_receipt_finance(
user_id: Optional[str],
) -> None:
if d.pay_status != "dfj":
raise ValueError("仅待缴费状态可提交财务确认")
raise ValueError("仅已缴费状态可提交财务确认")
d.pay_status = "jfqrz"
d.update_by = user_id
d.update_time = datetime.now()
@@ -2797,7 +2824,19 @@ def save_samples(
db.commit()
for o in result:
db.refresh(o)
save_sample_transports(db, collection_protocol_id, tenant_no, user_id, result, samples)
# 本阶段停写旧运输明细,改挂运费批次
from app.crud import sample_freight as sample_freight_svc
sample_freight_svc.apply_freight_on_protocol_save(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_source=sample_freight_svc.SAMPLE_SOURCE_CELL,
protocol_id=collection_protocol_id,
saved_samples=result,
input_samples=samples,
)
db.commit()
return result
@@ -3357,11 +3396,13 @@ def upsert_storage_cert(
) -> BizProtocolStorageCert:
fields = _normalize_fields(fields)
obj = get_storage_cert(db, collection_protocol_id, tenant_no)
_STR_FIELDS = ["make_status", "cert_image_urls", "make_date"]
_STR_FIELDS = ["make_status", "cert_image_urls", "make_date", "handler_user_id"]
if obj:
for k in _STR_FIELDS:
if k in fields:
setattr(obj, k, (fields[k] or "").strip() or None)
setattr(obj, k, (fields[k] or "").strip() or None if isinstance(fields[k], str) or fields[k] is None else fields[k])
if "handle_time" in fields:
obj.handle_time = fields.get("handle_time")
if "remark" in fields:
obj.remark = (fields["remark"] or "").strip() or None
obj.update_by = user_id
@@ -3385,13 +3426,49 @@ def upsert_storage_cert(
)
for k in _STR_FIELDS:
if k in fields:
setattr(obj, k, (fields[k] or "").strip() or None)
val = fields[k]
setattr(obj, k, (val or "").strip() or None if isinstance(val, str) or val is None else val)
if "handle_time" in fields:
obj.handle_time = fields.get("handle_time")
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def upsert_storage_cert_handle(
db: Session,
collection_protocol_id: str,
tenant_no: str,
user_id: Optional[str],
*,
handle_time: Optional[datetime] = None,
handler_user_id: Optional[str] = None,
cert_image_urls: Optional[str] = None,
remark: Optional[str] = None,
) -> BizProtocolStorageCert:
from app.services.sample_info_progress import apply_handle_payload
payload = apply_handle_payload(
handle_time=handle_time,
handler_user_id=handler_user_id,
attachments=cert_image_urls or "",
remark=remark or "",
)
return upsert_storage_cert(
db,
collection_protocol_id,
tenant_no,
user_id,
handle_time=payload["handle_time"],
handler_user_id=payload["handler_user_id"],
make_status=payload["make_or_todo_status"],
make_date=payload["legacy_date"] or None,
cert_image_urls=payload["attachments"] or None,
remark=payload["remark"] or None,
)
# ---------------------------------------------------------------------------
# 子表:满月待办
# ---------------------------------------------------------------------------
@@ -3401,6 +3478,9 @@ def _fullmoon_todo_empty_dict() -> Dict[str, Any]:
return {
"todoStatus": "0",
"handleDate": "",
"handleTime": "",
"handlerUserId": "",
"handled": False,
"handleRemark": "",
"insuranceAttachments": "",
"insuranceCompanyId": "",
@@ -3435,11 +3515,16 @@ def _fullmoon_todo_detail_dict(
def _fullmoon_todo_to_dict(ft: BizProtocolFullmoonTodo) -> Dict[str, Any]:
from app.services.sample_info_progress import insurance_handled
return {
"id": ft.id,
"collectionProtocolId": ft.collection_protocol_id,
"todoStatus": ft.todo_status or "0",
"handleDate": (ft.handle_date or "").strip() if ft.handle_date else "",
"handleTime": _fmt_dt(ft.handle_time) if getattr(ft, "handle_time", None) else "",
"handlerUserId": getattr(ft, "handler_user_id", None) or "",
"handled": insurance_handled(ft),
"handleRemark": ft.handle_remark or "",
"insuranceAttachments": ft.insurance_attachments or "",
"insuranceCompanyId": ft.insurance_company_id or "",
@@ -3486,7 +3571,7 @@ def upsert_fullmoon_todo(
fields = _normalize_fields(fields)
obj = get_fullmoon_todo(db, collection_protocol_id, tenant_no)
_STR_FIELDS = [
"todo_status", "handle_date", "handle_remark",
"todo_status", "handle_date", "handle_remark", "handler_user_id",
"insurance_attachments", "insurance_company_id",
"insurance_company_name", "insurance_type",
"insurance_policy_no", "insurance_effective_date",
@@ -3497,6 +3582,8 @@ def upsert_fullmoon_todo(
for k in _STR_FIELDS:
if k in fields:
setattr(obj, k, (fields[k] or "").strip() or None)
if "handle_time" in fields:
obj.handle_time = fields.get("handle_time")
for k in _DEC_FIELDS:
if k in fields and fields[k] is not None:
setattr(obj, k, Decimal(str(fields[k])))
@@ -3524,6 +3611,8 @@ def upsert_fullmoon_todo(
for k in _STR_FIELDS:
if k in fields:
setattr(obj, k, (fields[k] or "").strip() or None)
if "handle_time" in fields:
obj.handle_time = fields.get("handle_time")
for k in _DEC_FIELDS:
if k in fields and fields[k] is not None:
setattr(obj, k, Decimal(str(fields[k])))
@@ -3533,6 +3622,39 @@ def upsert_fullmoon_todo(
return obj
def upsert_fullmoon_todo_handle(
db: Session,
collection_protocol_id: str,
tenant_no: str,
user_id: Optional[str],
*,
handle_time: Optional[datetime] = None,
handler_user_id: Optional[str] = None,
insurance_attachments: Optional[str] = None,
handle_remark: Optional[str] = None,
) -> BizProtocolFullmoonTodo:
from app.services.sample_info_progress import apply_handle_payload
payload = apply_handle_payload(
handle_time=handle_time,
handler_user_id=handler_user_id,
attachments=insurance_attachments or "",
remark=handle_remark or "",
)
return upsert_fullmoon_todo(
db,
collection_protocol_id,
tenant_no,
user_id,
handle_time=payload["handle_time"],
handler_user_id=payload["handler_user_id"],
todo_status=payload["make_or_todo_status"],
handle_date=payload["legacy_date"] or None,
insurance_attachments=payload["attachments"] or None,
handle_remark=payload["remark"] or None,
)
# ---------------------------------------------------------------------------
# 开票申请(主表 + 明细子表)
# ---------------------------------------------------------------------------
+42 -7
View File
@@ -872,10 +872,19 @@ def get_donation_detail(
)
samples_out: List[Dict[str, Any]] = []
unit_name_map = _load_unit_name_map(db, s_rows)
from app.crud import sample_freight as sample_freight_svc
freight_by_sample = sample_freight_svc.freight_map_for_samples(
db,
tenant_no=tenant_no,
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
sample_ids=sample_ids,
)
for s in s_rows:
item = _sample_to_dict(s)
item["unitName"] = unit_name_map.get(s.unit_id or "", "")
item["transport"] = transport_by_sample.get(s.id)
item["freight"] = freight_by_sample.get(s.id)
item["sampleReception"] = reception_by_sample.get(s.id)
item["productionReception"] = (reception_by_sample.get(s.id) or {}).get(
"productionReception"
@@ -967,12 +976,24 @@ def create_donation(
coll_raw = _normalize_fields(raw.get("collection") or {})
_create_collection(db, donation_id, tenant_no, user_id, coll_raw, now)
# samples + transports
# samples(运费批次在 commit 后挂钩;停写旧运输依赖)
samples_raw = raw.get("samples") or []
_create_samples(db, donation_id, tenant_no, user_id, samples_raw, now)
new_samples = _create_samples(db, donation_id, tenant_no, user_id, samples_raw, now)
db.commit()
db.refresh(obj)
from app.crud import sample_freight as sample_freight_svc
sample_freight_svc.apply_freight_on_protocol_save(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
protocol_id=donation_id,
saved_samples=new_samples,
input_samples=samples_raw,
)
db.commit()
return obj
@@ -1067,7 +1088,8 @@ def update_donation(
}
_soft_delete_samples(db, donation_id, tenant_no, user_id)
_soft_delete_transports(db, donation_id, tenant_no, user_id)
new_samples = _create_samples(db, donation_id, tenant_no, user_id, raw["samples"] or [], now)
samples_raw = raw["samples"] or []
new_samples = _create_samples(db, donation_id, tenant_no, user_id, samples_raw, now)
new_code_to_id = {
(s.sample_code or "").strip(): s.id
for s in new_samples
@@ -1080,9 +1102,25 @@ def update_donation(
new_code_to_id=new_code_to_id,
user_id=user_id,
)
else:
samples_raw = None
new_samples = None
db.commit()
db.refresh(obj)
if new_samples is not None and samples_raw is not None:
from app.crud import sample_freight as sample_freight_svc
sample_freight_svc.apply_freight_on_protocol_save(
db,
tenant_no=tenant_no,
user_id=user_id,
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
protocol_id=donation_id,
saved_samples=new_samples,
input_samples=samples_raw,
)
db.commit()
return obj
@@ -2101,10 +2139,7 @@ def _create_samples(
db.flush()
result.append(obj)
# transport
transport_raw = raw.get("transport")
if isinstance(transport_raw, dict):
_create_transport(db, donation_id, obj.id, tenant_no, user_id, transport_raw, idx, now, sample_code=obj.sample_code)
# 本阶段不再写入旧运输明细;运费由 apply_freight_on_protocol_save 挂钩
return result
+2 -1
View File
@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
from app.core.api_request_log_middleware import ApiRequestLogMiddleware
from app.core.api_crypto_middleware import ApiCryptoMiddleware
from app.models import models # noqa: F401
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router
@asynccontextmanager
@@ -120,6 +120,7 @@ app.include_router(collection_protocol_router, prefix=settings.API_V1_PREFIX)
app.include_router(protocol_sample_payment_router, prefix=settings.API_V1_PREFIX)
app.include_router(protocol_refund_record_router, prefix=settings.API_V1_PREFIX)
app.include_router(presign_order_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_freight_router, prefix=settings.API_V1_PREFIX)
app.include_router(leave_router, prefix=settings.API_V1_PREFIX)
app.include_router(operations_router, prefix=settings.API_V1_PREFIX)
app.include_router(ethnic_router, prefix=settings.API_V1_PREFIX)