diff --git a/backend/app/crud/sample_freight.py b/backend/app/crud/sample_freight.py index 363dca25..26ef7008 100644 --- a/backend/app/crud/sample_freight.py +++ b/backend/app/crud/sample_freight.py @@ -949,11 +949,13 @@ def link_samples( items: List[Dict[str, Any]], commit: bool = True, user_info: Optional[dict] = None, + allow_settled_relink: bool = False, ) -> Dict[str, Any]: header = get_freight(db, freight_id, tenant_no, user_info) if not header: raise ValueError("运费批次不存在") - assert_freight_mutable(header.settle_status, "items") + if not allow_settled_relink: + assert_freight_mutable(header.settle_status, "items") if not items: raise ValueError("请选择要关联的样本") if user_info is not None: @@ -1156,6 +1158,16 @@ def freight_map_for_samples( return out +def _input_freight_id(raw: Any) -> str: + if not isinstance(raw, dict): + return "" + nested = raw.get("freight") if isinstance(raw.get("freight"), dict) else {} + return ( + str(raw.get("freightId") or raw.get("freight_id") or "").strip() + or str(nested.get("id") or "").strip() + ) + + def apply_freight_on_protocol_save( db: Session, *, @@ -1190,6 +1202,34 @@ def apply_freight_on_protocol_save( ) now = _now() affected_freight_ids = {item.freight_id for item in stale_items} + stale_by_freight: Dict[str, List[BizSampleFreightItem]] = {} + for item in stale_items: + stale_by_freight.setdefault(item.freight_id, []).append(item) + settled_relink_ids = set() + if stale_by_freight: + headers = ( + db.query(BizSampleFreight) + .filter( + BizSampleFreight.id.in_(list(stale_by_freight.keys())), + BizSampleFreight.tenant_no == tenant_no, + BizSampleFreight.del_flag == 0, + ) + .all() + ) + header_by_id = {row.id: row for row in headers} + for freight_id, grouped in stale_by_freight.items(): + header = header_by_id.get(freight_id) + if header is None or (header.settle_status or "") != "yjs": + continue + stale_count = len(grouped) + keep_count = sum( + 1 + for raw in (input_samples or []) + if _input_freight_id(raw) == freight_id + ) + if keep_count != stale_count: + raise ValueError("已结算的运费不能调整样本") + settled_relink_ids.add(freight_id) for stale in stale_items: stale.del_flag = 1 stale.update_by = user_id @@ -1218,10 +1258,7 @@ def apply_freight_on_protocol_save( "protocolId": protocol_id, "sampleCode": sample_code, } - freight_id = ( - str(raw.get("freightId") or raw.get("freight_id") or "").strip() - or str((raw.get("freight") or {}).get("id") or "").strip() - ) + freight_id = _input_freight_id(raw) draft = raw.get("freightDraft") if draft is None: draft = raw.get("freight_draft") @@ -1233,6 +1270,7 @@ def apply_freight_on_protocol_save( user_id=user_id, items=[item], commit=False, + allow_settled_relink=freight_id in settled_relink_ids, ) continue if isinstance(draft, dict): @@ -1281,6 +1319,7 @@ def apply_freight_on_protocol_save( .first() ) if freight is not None: + assert_freight_mutable(freight.settle_status, "delete") freight.del_flag = 1 freight.update_by = user_id freight.update_time = now diff --git a/backend/tests/test_sf_express_settlement.py b/backend/tests/test_sf_express_settlement.py index 40e9c89f..23ea153f 100644 --- a/backend/tests/test_sf_express_settlement.py +++ b/backend/tests/test_sf_express_settlement.py @@ -259,3 +259,129 @@ def test_admin_and_market_bodies_accept_sf_without_amount(): }) assert draft.waybill_no == "SF100" MarketFreightUpdateBody.model_validate({"waybillNo": "SF100"}) + + +def _protocol_freight_db(): + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + from app.models.base import Base + from app.models.models import ( + BizCellSampleType, + BizCollectionProtocol, + BizProtocolPartyCustomer, + BizProtocolSample, + BizSampleFreight, + BizSampleFreightItem, + SysDictData, + ) + + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + engine, + tables=[ + BizSampleFreight.__table__, + BizSampleFreightItem.__table__, + BizProtocolSample.__table__, + BizCollectionProtocol.__table__, + BizCellSampleType.__table__, + BizProtocolPartyCustomer.__table__, + SysDictData.__table__, + ], + ) + return sessionmaker(bind=engine)() + + +def _seed_settled_protocol_freight(db, sample_id="s-old"): + from app.models.models import BizSampleFreight, BizSampleFreightItem + + now = datetime(2026, 10, 21, 9, 0, 0) + freight = BizSampleFreight( + id="f-yjs", + freight_no="YF1", + transport_mode="sfkd", + freight_amount=None, + waybill_no="SF001", + settle_status="yjs", + tenant_no="tenant-a", + create_dept_id="", + create_time=now, + update_time=now, + del_flag=0, + ) + item = BizSampleFreightItem( + id="fi-1", + freight_id=freight.id, + sample_source="cell_storage", + sample_id=sample_id, + sample_code="C1", + protocol_id="p1", + tenant_no="tenant-a", + create_dept_id="", + create_time=now, + update_time=now, + del_flag=0, + ) + db.add_all([freight, item]) + db.commit() + return freight, item + + +def test_settled_protocol_save_without_freight_id_keeps_links(): + from types import SimpleNamespace + + from app.crud.sample_freight import apply_freight_on_protocol_save + from app.models.models import BizSampleFreight, BizSampleFreightItem + + db = _protocol_freight_db() + freight, item = _seed_settled_protocol_freight(db) + try: + with pytest.raises(ValueError, match="已结算的运费不能调整样本"): + apply_freight_on_protocol_save( + db, + tenant_no="tenant-a", + user_id="u1", + sample_source="cell_storage", + protocol_id="p1", + saved_samples=[SimpleNamespace(id="s-new", sample_code="C2")], + input_samples=[{}], + ) + db.expire_all() + item = db.query(BizSampleFreightItem).filter_by(id=item.id).one() + freight = db.query(BizSampleFreight).filter_by(id=freight.id).one() + assert item.del_flag == 0 + assert freight.del_flag == 0 + finally: + db.close() + + +def test_settled_protocol_save_relinks_same_count_to_new_sample(): + from types import SimpleNamespace + + from app.crud.sample_freight import apply_freight_on_protocol_save + from app.models.models import BizSampleFreight, BizSampleFreightItem + + db = _protocol_freight_db() + freight, _item = _seed_settled_protocol_freight(db) + try: + apply_freight_on_protocol_save( + db, + tenant_no="tenant-a", + user_id="u1", + sample_source="cell_storage", + protocol_id="p1", + saved_samples=[SimpleNamespace(id="s-new", sample_code="C2")], + input_samples=[{"freightId": freight.id}], + ) + db.expire_all() + freight = db.query(BizSampleFreight).filter_by(id=freight.id).one() + assert freight.del_flag == 0 + assert freight.settle_status == "yjs" + live = ( + db.query(BizSampleFreightItem) + .filter_by(freight_id=freight.id, sample_id="s-new", del_flag=0) + .one() + ) + assert live.protocol_id == "p1" + finally: + db.close()