diff --git a/backend/app/api/sample_freight.py b/backend/app/api/sample_freight.py index 063f62fd..3422c80c 100644 --- a/backend/app/api/sample_freight.py +++ b/backend/app/api/sample_freight.py @@ -69,6 +69,11 @@ 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, + transportMode: Optional[str] = None, + sampleSource: Optional[str] = None, + createTimeFrom: Optional[str] = None, + createTimeTo: Optional[str] = None, + includeItems: bool = Query(False), user: dict = Depends(require_admin_or_super_admin), db: Session = Depends(get_db), ): @@ -80,12 +85,19 @@ async def list_sample_freights( page=page, page_size=pageSize, keyword=keyword, + transport_mode=transportMode, + sample_source=sampleSource, + create_time_from=createTimeFrom, + create_time_to=createTimeTo, + include_items=includeItems, ) return success_response( {"list": items, "total": total, "page": page, "pageSize": pageSize} ) except PermissionError as e: return error_response(403, str(e)) + except ValueError as e: + return error_response(400, str(e)) @router.get("/sample-freights/{freight_id}") diff --git a/backend/app/crud/sample_freight.py b/backend/app/crud/sample_freight.py index 4264a0ac..938ce4ed 100644 --- a/backend/app/crud/sample_freight.py +++ b/backend/app/crud/sample_freight.py @@ -2,15 +2,23 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from decimal import Decimal from typing import Any, Dict, List, Optional, Tuple -from sqlalchemy import and_, func, or_ +from sqlalchemy import and_, exists, func, or_ from sqlalchemy.orm import Session from app.core.utils import generate_uuid -from app.models.models import BizSampleFreight, BizSampleFreightItem, SysDictData +from app.models.models import ( + BizCellSampleType, + BizCollectionProtocol, + BizProtocolSample, + BizSampleFreight, + BizSampleFreightItem, + SysDictData, +) +from app.models.research_donation import BizResearchDonation, BizResearchDonationSample from app.services.sample_transport_mode import DICT_TYPE, resolve_transport_mode_value SAMPLE_SOURCE_CELL = "cell_storage" @@ -127,6 +135,128 @@ def _item_to_dict(item: BizSampleFreightItem) -> Dict[str, Any]: } +def _item_to_dict_enriched( + item: BizSampleFreightItem, + *, + protocol_no: str = "", + sample_type_name: str = "", +) -> Dict[str, Any]: + data = _item_to_dict(item) + data["protocolNo"] = protocol_no or "" + data["sampleTypeName"] = sample_type_name or "" + return data + + +def _normalize_date_bound(value: str, *, end_of_day: bool = False) -> datetime: + raw = (value or "").strip() + if not raw: + raise ValueError("时间参数无效") + for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"): + try: + dt = datetime.strptime(raw, fmt) + if fmt == "%Y-%m-%d" and end_of_day: + dt = dt + timedelta(days=1) - timedelta(seconds=1) + return dt + except ValueError: + continue + try: + return datetime.fromisoformat(raw.replace("Z", "+00:00")).replace(tzinfo=None) + except ValueError as exc: + raise ValueError("时间参数格式无效") from exc + + +def _enrich_items_maps( + db: Session, + items: List[BizSampleFreightItem], + tenant_no: str, +) -> Tuple[Dict[str, str], Dict[str, str]]: + """批量解析 protocolNo / sampleTypeName,key = sample_source|sample_id。""" + protocol_no_map: Dict[str, str] = {} + type_name_map: Dict[str, str] = {} + if not items: + return protocol_no_map, type_name_map + + cell_ids = [ + i.sample_id + for i in items + if i.sample_source == SAMPLE_SOURCE_CELL and i.sample_id + ] + donation_ids = [ + i.sample_id + for i in items + if i.sample_source == SAMPLE_SOURCE_DONATION and i.sample_id + ] + + if cell_ids: + rows = ( + db.query( + BizProtocolSample.id, + BizCollectionProtocol.protocol_no, + BizCellSampleType.type_name, + ) + .outerjoin( + BizCollectionProtocol, + and_( + BizCollectionProtocol.id == BizProtocolSample.collection_protocol_id, + BizCollectionProtocol.del_flag == 0, + ), + ) + .outerjoin( + BizCellSampleType, + and_( + BizCellSampleType.id == BizProtocolSample.sample_type_id, + BizCellSampleType.del_flag == 0, + ), + ) + .filter( + BizProtocolSample.id.in_(cell_ids), + BizProtocolSample.tenant_no == tenant_no, + BizProtocolSample.del_flag == 0, + ) + .all() + ) + for sid, pno, tname in rows: + key = f"{SAMPLE_SOURCE_CELL}|{sid}" + protocol_no_map[key] = pno or "" + type_name_map[key] = tname or "" + + if donation_ids: + rows = ( + db.query( + BizResearchDonationSample.id, + BizResearchDonation.donation_no, + BizCellSampleType.type_name, + ) + .outerjoin( + BizResearchDonation, + and_( + BizResearchDonation.id + == BizResearchDonationSample.research_donation_id, + BizResearchDonation.del_flag == 0, + ), + ) + .outerjoin( + BizCellSampleType, + and_( + BizCellSampleType.id == BizResearchDonationSample.sample_type_id, + BizCellSampleType.del_flag == 0, + ), + ) + .filter( + BizResearchDonationSample.id.in_(donation_ids), + BizResearchDonationSample.tenant_no == tenant_no, + BizResearchDonationSample.del_flag == 0, + ) + .all() + ) + for sid, dno, tname in rows: + key = f"{SAMPLE_SOURCE_DONATION}|{sid}" + protocol_no_map[key] = dno or "" + type_name_map[key] = tname or "" + + return protocol_no_map, type_name_map + + def get_freight(db: Session, freight_id: str, tenant_no: str) -> Optional[BizSampleFreight]: return ( db.query(BizSampleFreight) @@ -146,6 +276,11 @@ def list_freights( page: int = 1, page_size: int = 10, keyword: Optional[str] = None, + transport_mode: Optional[str] = None, + sample_source: Optional[str] = None, + create_time_from: Optional[str] = None, + create_time_to: Optional[str] = None, + include_items: bool = False, ) -> Tuple[List[Dict[str, Any]], int]: page = max(1, int(page or 1)) page_size = max(1, min(100, int(page_size or 10))) @@ -153,6 +288,36 @@ def list_freights( BizSampleFreight.tenant_no == tenant_no, BizSampleFreight.del_flag == 0, ) + + mode = (transport_mode or "").strip() + if mode: + q = q.filter(BizSampleFreight.transport_mode == mode) + + src = (sample_source or "").strip() + if src: + if src not in (SAMPLE_SOURCE_CELL, SAMPLE_SOURCE_DONATION): + raise ValueError("sampleSource 无效") + item_exists = exists().where( + and_( + BizSampleFreightItem.freight_id == BizSampleFreight.id, + BizSampleFreightItem.tenant_no == tenant_no, + BizSampleFreightItem.del_flag == 0, + BizSampleFreightItem.sample_source == src, + ) + ) + q = q.filter(item_exists) + + if create_time_from and str(create_time_from).strip(): + q = q.filter( + BizSampleFreight.create_time + >= _normalize_date_bound(str(create_time_from).strip()) + ) + if create_time_to and str(create_time_to).strip(): + q = q.filter( + BizSampleFreight.create_time + <= _normalize_date_bound(str(create_time_to).strip(), end_of_day=True) + ) + kw = (keyword or "").strip() if kw: like = f"%{kw}%" @@ -170,7 +335,47 @@ def list_freights( conds = [BizSampleFreight.transport_mode.like(like)] if label_values: conds.append(BizSampleFreight.transport_mode.in_(label_values)) + + sample_code_exists = exists().where( + and_( + BizSampleFreightItem.freight_id == BizSampleFreight.id, + BizSampleFreightItem.tenant_no == tenant_no, + BizSampleFreightItem.del_flag == 0, + BizSampleFreightItem.sample_code.like(like), + ) + ) + conds.append(sample_code_exists) + + cell_proto_exists = exists().where( + and_( + BizSampleFreightItem.freight_id == BizSampleFreight.id, + BizSampleFreightItem.tenant_no == tenant_no, + BizSampleFreightItem.del_flag == 0, + BizSampleFreightItem.sample_source == SAMPLE_SOURCE_CELL, + BizSampleFreightItem.protocol_id == BizCollectionProtocol.id, + BizCollectionProtocol.tenant_no == tenant_no, + BizCollectionProtocol.del_flag == 0, + BizCollectionProtocol.protocol_no.like(like), + ) + ) + conds.append(cell_proto_exists) + + donation_proto_exists = exists().where( + and_( + BizSampleFreightItem.freight_id == BizSampleFreight.id, + BizSampleFreightItem.tenant_no == tenant_no, + BizSampleFreightItem.del_flag == 0, + BizSampleFreightItem.sample_source == SAMPLE_SOURCE_DONATION, + BizSampleFreightItem.protocol_id == BizResearchDonation.id, + BizResearchDonation.tenant_no == tenant_no, + BizResearchDonation.del_flag == 0, + BizResearchDonation.donation_no.like(like), + ) + ) + conds.append(donation_proto_exists) + q = q.filter(or_(*conds)) + total = q.count() rows = ( q.order_by(BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc()) @@ -179,14 +384,54 @@ def list_freights( .all() ) labels = _label_map(db, tenant_no, [r.transport_mode for r in rows]) - out = [ - _row_to_dict( + + items_by_freight: Dict[str, List[BizSampleFreightItem]] = {} + if include_items and rows: + freight_ids = [r.id for r in rows] + all_items = ( + db.query(BizSampleFreightItem) + .filter( + BizSampleFreightItem.freight_id.in_(freight_ids), + BizSampleFreightItem.tenant_no == tenant_no, + BizSampleFreightItem.del_flag == 0, + ) + .order_by( + BizSampleFreightItem.create_time.asc(), + BizSampleFreightItem.id.asc(), + ) + .all() + ) + for it in all_items: + items_by_freight.setdefault(it.freight_id, []).append(it) + protocol_no_map, type_name_map = _enrich_items_maps(db, all_items, tenant_no) + else: + protocol_no_map, type_name_map = {}, {} + + out: List[Dict[str, Any]] = [] + for r in rows: + linked = ( + len(items_by_freight.get(r.id, [])) + if include_items + else _linked_count(db, r.id, tenant_no) + ) + data = _row_to_dict( r, - linked_sample_count=_linked_count(db, r.id, tenant_no), + linked_sample_count=linked, transport_mode_label=labels.get(r.transport_mode or "", r.transport_mode or ""), ) - for r in rows - ] + if include_items: + enriched = [] + for it in items_by_freight.get(r.id, []): + key = f"{it.sample_source}|{it.sample_id}" + enriched.append( + _item_to_dict_enriched( + it, + protocol_no=protocol_no_map.get(key, ""), + sample_type_name=type_name_map.get(key, ""), + ) + ) + data["items"] = enriched + out.append(data) return out, total diff --git a/backend/tests/test_sample_freight.py b/backend/tests/test_sample_freight.py index 4b4dc82e..b3b250ac 100644 --- a/backend/tests/test_sample_freight.py +++ b/backend/tests/test_sample_freight.py @@ -61,5 +61,49 @@ class TestFreightAmountValidation(unittest.TestCase): _parse_amount("") +class TestFreightListHelpers(unittest.TestCase): + def test_item_dict_has_enrichment_keys(self): + from app.crud.sample_freight import _item_to_dict_enriched + + class I: + id = "i1" + freight_id = "f1" + sample_source = "cell_storage" + sample_id = "s1" + sample_code = "CODE1" + protocol_id = "p1" + + d = _item_to_dict_enriched( + I(), + protocol_no="CP001", + sample_type_name="脐带血", + ) + self.assertEqual(d["protocolNo"], "CP001") + self.assertEqual(d["sampleTypeName"], "脐带血") + self.assertEqual(d["sampleSource"], "cell_storage") + + +class TestFreightListHelpers(unittest.TestCase): + def test_item_dict_has_enrichment_keys(self): + from app.crud.sample_freight import _item_to_dict_enriched + + class I: + id = "i1" + freight_id = "f1" + sample_source = "cell_storage" + sample_id = "s1" + sample_code = "CODE1" + protocol_id = "p1" + + d = _item_to_dict_enriched( + I(), + protocol_no="CP001", + sample_type_name="脐带血", + ) + self.assertEqual(d["protocolNo"], "CP001") + self.assertEqual(d["sampleTypeName"], "脐带血") + self.assertEqual(d["sampleSource"], "cell_storage") + + if __name__ == "__main__": unittest.main()