diff --git a/backend/app/api/market_mobile.py b/backend/app/api/market_mobile.py index 32fc8517..cd442651 100644 --- a/backend/app/api/market_mobile.py +++ b/backend/app/api/market_mobile.py @@ -900,6 +900,7 @@ def _register_freight_routes(scope_kind: ScopeKind) -> None: user_info=user, transport_mode=body.transport_mode, freight_amount=body.freight_amount, + waybill_no=body.waybill_no, items=[ item.model_dump(by_alias=True) for item in body.items ], @@ -925,6 +926,9 @@ def _register_freight_routes(scope_kind: ScopeKind) -> None: user_info=user, transport_mode=body.transport_mode, freight_amount=body.freight_amount, + waybill_no=body.waybill_no, + waybill_supplied='waybill_no' in body.model_fields_set, + amount_supplied='freight_amount' in body.model_fields_set, ), message='保存成功', ) diff --git a/backend/app/api/sample_freight.py b/backend/app/api/sample_freight.py index 3f852655..509f5172 100644 --- a/backend/app/api/sample_freight.py +++ b/backend/app/api/sample_freight.py @@ -47,7 +47,8 @@ class SampleFreightCreateBody(BaseModel): model_config = ConfigDict(extra="ignore", populate_by_name=True) transport_mode: str = Field(..., alias="transportMode") - freight_amount: float = Field(..., alias="freightAmount") + freight_amount: Optional[float] = Field(None, alias="freightAmount") + waybill_no: Optional[str] = Field(None, alias="waybillNo") items: Optional[List[SampleFreightItemBody]] = None @@ -56,6 +57,7 @@ class SampleFreightUpdateBody(BaseModel): transport_mode: Optional[str] = Field(None, alias="transportMode") freight_amount: Optional[float] = Field(None, alias="freightAmount") + waybill_no: Optional[str] = Field(None, alias="waybillNo") class SampleFreightItemsBody(BaseModel): @@ -163,6 +165,9 @@ async def create_sample_freight( user_id=user_id, transport_mode_raw=body.transport_mode, freight_amount=body.freight_amount, + waybill_no=body.waybill_no, + waybill_supplied=True, + amount_supplied=True, items=items, ) return success_response(data, message="创建成功") @@ -190,6 +195,9 @@ async def update_sample_freight( user_id=user_id, transport_mode_raw=body.transport_mode, freight_amount=body.freight_amount, + waybill_no=body.waybill_no, + waybill_supplied="waybill_no" in body.model_fields_set, + amount_supplied="freight_amount" in body.model_fields_set, ) return success_response(data, message="更新成功") except PermissionError as e: diff --git a/backend/app/crud/sample_freight.py b/backend/app/crud/sample_freight.py index fad0041d..363dca25 100644 --- a/backend/app/crud/sample_freight.py +++ b/backend/app/crud/sample_freight.py @@ -27,7 +27,11 @@ from app.models.research_donation import ( BizResearchDonationSample, ) from app.services.sample_transport_mode import DICT_TYPE, resolve_transport_mode_value -from app.services.sf_express_settlement import freight_amount_label +from app.services.sf_express_settlement import ( + assert_freight_mutable, + freight_amount_label, + resolve_header_for_save, +) SAMPLE_SOURCE_CELL = "cell_storage" SAMPLE_SOURCE_DONATION = "research_donation" @@ -839,19 +843,34 @@ def create_freight( items: Optional[List[Dict[str, Any]]] = None, commit: bool = True, user_info: Optional[dict] = None, + waybill_no: Any = None, + waybill_supplied: bool = True, + amount_supplied: bool = True, ) -> Dict[str, Any]: if user_info is not None: items = _validated_items(db, items or [], tenant_no, user_info) mode = resolve_transport_mode_value( db, tenant_no=tenant_no, raw=transport_mode_raw, user_id=user_id ) - amount = _parse_amount(freight_amount) + plan = resolve_header_for_save( + transport_mode=mode, + existing_waybill=None, + existing_amount=None, + existing_settle_status=None, + waybill_supplied=waybill_supplied, + waybill_no=waybill_no, + amount_supplied=amount_supplied, + freight_amount=freight_amount, + ) now = _now() obj = BizSampleFreight( id=generate_uuid(), freight_no=_generate_freight_no(db, tenant_no, when=now), - transport_mode=mode, - freight_amount=amount, + transport_mode=plan.transport_mode, + freight_amount=plan.freight_amount, + waybill_no=plan.waybill_no, + settle_status=plan.settle_status, + settle_time=None, tenant_no=tenant_no, order_num=0, remark=None, @@ -883,16 +902,34 @@ def update_freight( freight_amount: Any = None, commit: bool = True, user_info: Optional[dict] = None, + waybill_no: Any = None, + waybill_supplied: bool = False, + amount_supplied: bool = False, ) -> Dict[str, Any]: obj = get_freight(db, freight_id, tenant_no, user_info) if not obj: raise ValueError("运费批次不存在") + mode = obj.transport_mode or "" if transport_mode_raw is not None and str(transport_mode_raw).strip() != "": - obj.transport_mode = resolve_transport_mode_value( + mode = resolve_transport_mode_value( db, tenant_no=tenant_no, raw=str(transport_mode_raw), user_id=user_id ) - if freight_amount is not None and freight_amount != "": - obj.freight_amount = _parse_amount(freight_amount) + plan = resolve_header_for_save( + transport_mode=mode, + existing_waybill=obj.waybill_no, + existing_amount=obj.freight_amount, + existing_settle_status=obj.settle_status, + waybill_supplied=waybill_supplied, + waybill_no=waybill_no, + amount_supplied=amount_supplied, + freight_amount=freight_amount, + ) + obj.transport_mode = plan.transport_mode + obj.waybill_no = plan.waybill_no + obj.freight_amount = plan.freight_amount + obj.settle_status = plan.settle_status + if plan.clear_settle_time: + obj.settle_time = None obj.update_by = user_id obj.update_time = _now() db.add(obj) @@ -913,9 +950,10 @@ def link_samples( commit: bool = True, user_info: Optional[dict] = None, ) -> Dict[str, Any]: - obj = get_freight(db, freight_id, tenant_no, user_info) - if not obj: + header = get_freight(db, freight_id, tenant_no, user_info) + if not header: raise ValueError("运费批次不存在") + assert_freight_mutable(header.settle_status, "items") if not items: raise ValueError("请选择要关联的样本") if user_info is not None: @@ -939,9 +977,11 @@ def unlink_samples( commit: bool = True, user_info: Optional[dict] = None, ) -> Dict[str, Any]: - obj = get_freight(db, freight_id, tenant_no, user_info) - if not obj: + header = get_freight(db, freight_id, tenant_no, user_info) + if not header: raise ValueError("运费批次不存在") + assert_freight_mutable(header.settle_status, "items") + obj = header now = _now() for it in items or []: norm = _normalize_item(it) @@ -992,8 +1032,10 @@ def delete_freight( user_info: Optional[dict] = None, ) -> Dict[str, Any]: """级联软删运费批次及其全部有效关联样本。""" - if user_info is not None and not get_freight(db, freight_id, tenant_no, user_info): + header = get_freight(db, freight_id, tenant_no, user_info) + if not header: raise ValueError("运费批次不存在") + assert_freight_mutable(header.settle_status, "delete") obj = ( db.query(BizSampleFreight) .filter( @@ -1204,6 +1246,9 @@ def apply_freight_on_protocol_save( "freightAmount": draft.get("freightAmount") if draft.get("freightAmount") is not None else draft.get("freight_amount"), + "waybillNo": draft.get("waybillNo") + if draft.get("waybillNo") is not None + else draft.get("waybill_no"), "items": [item], } else: @@ -1216,6 +1261,9 @@ def apply_freight_on_protocol_save( user_id=user_id, transport_mode_raw=str(group.get("transportMode") or ""), freight_amount=group.get("freightAmount"), + waybill_no=group.get("waybillNo"), + waybill_supplied=True, + amount_supplied=True, items=group["items"], commit=False, ) diff --git a/backend/app/schemas/market_mobile.py b/backend/app/schemas/market_mobile.py index bbce387a..926cf6e8 100644 --- a/backend/app/schemas/market_mobile.py +++ b/backend/app/schemas/market_mobile.py @@ -217,6 +217,7 @@ class MarketProtocolSampleFreightDraftBody(BaseModel): client_key: Optional[str] = Field(None, alias='clientKey') transport_mode: Optional[str] = Field(None, alias='transportMode') freight_amount: Optional[MarketContractAmount] = Field(None, alias='freightAmount') + waybill_no: Optional[str] = Field(None, alias='waybillNo') class MarketProtocolSampleBody(BaseModel): @@ -486,10 +487,11 @@ class MarketFreightCreateBody(MarketFreightItemsBody): model_config = ConfigDict(extra='forbid', populate_by_name=True) transport_mode: str = Field(..., min_length=1, max_length=100, alias='transportMode') - freight_amount: Decimal = Field( - ..., ge=Decimal('0'), max_digits=18, decimal_places=2, + freight_amount: Optional[Decimal] = Field( + None, ge=Decimal('0'), max_digits=18, decimal_places=2, allow_inf_nan=False, alias='freightAmount' ) + waybill_no: Optional[str] = Field(None, alias='waybillNo') class MarketFreightUpdateBody(BaseModel): @@ -502,3 +504,4 @@ class MarketFreightUpdateBody(BaseModel): None, ge=Decimal('0'), max_digits=18, decimal_places=2, allow_inf_nan=False, alias='freightAmount' ) + waybill_no: Optional[str] = Field(None, alias='waybillNo') diff --git a/backend/app/services/market_freight.py b/backend/app/services/market_freight.py index 5e2e566a..812c3abb 100644 --- a/backend/app/services/market_freight.py +++ b/backend/app/services/market_freight.py @@ -43,7 +43,11 @@ from app.services.market_field_scope import ( apply_donation_scope, ) from app.services.sample_transport_mode import DICT_TYPE, resolve_transport_mode_value -from app.services.sf_express_settlement import freight_amount_label +from app.services.sf_express_settlement import ( + assert_freight_mutable, + freight_amount_label, + resolve_header_for_save, +) from app.services.web_auth_roles import ( _map_role_name_to_web, resolve_web_role_for_user, @@ -1056,7 +1060,7 @@ def _next_freight_no(db: Session, tenant_no: str, now: datetime, offset: int = 0 return f'{prefix}{seq:03d}' -def create_freight(db: Session, *, ctx: MarketFieldContext, user_info: dict, transport_mode: str, freight_amount: Any, items: List[Dict[str, Any]]): +def create_freight(db: Session, *, ctx: MarketFieldContext, user_info: dict, transport_mode: str, freight_amount: Any, items: List[Dict[str, Any]], waybill_no: Any = None): keys = _normalize_items(items) try: samples = _lock_sample_graph(db, keys, ctx, user_info) @@ -1066,11 +1070,20 @@ def create_freight(db: Session, *, ctx: MarketFieldContext, user_info: dict, tra if conflicts: raise ValueError('样本已关联其他运费批次') mode = resolve_transport_mode_value(db, tenant_no=ctx.tenant_no, raw=transport_mode, user_id=ctx.user_id) - amount = _amount(freight_amount) + plan = resolve_header_for_save( + transport_mode=mode, + existing_waybill=None, + existing_amount=None, + existing_settle_status=None, + waybill_supplied=True, + waybill_no=waybill_no, + amount_supplied=True, + freight_amount=freight_amount, + ) now = datetime.now() obj = None for attempt in range(3): - candidate = BizSampleFreight(id=generate_uuid(), freight_no=_next_freight_no(db, ctx.tenant_no, now, attempt), transport_mode=mode, freight_amount=amount, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0) + candidate = BizSampleFreight(id=generate_uuid(), freight_no=_next_freight_no(db, ctx.tenant_no, now, attempt), transport_mode=plan.transport_mode, freight_amount=plan.freight_amount, waybill_no=plan.waybill_no, settle_status=plan.settle_status, settle_time=None, tenant_no=ctx.tenant_no, order_num=0, remark=None, create_by=ctx.user_id, create_dept_id=str(user_info.get('dept_id') or user_info.get('region_id') or ''), create_time=now, update_by=ctx.user_id, update_time=now, del_flag=0) try: with db.begin_nested(): db.add(candidate) @@ -1095,7 +1108,7 @@ def create_freight(db: Session, *, ctx: MarketFieldContext, user_info: dict, tra raise -def update_freight(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, transport_mode: Optional[str] = None, freight_amount: Any = None): +def update_freight(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_info: dict, transport_mode: Optional[str] = None, freight_amount: Any = None, waybill_no: Any = None, waybill_supplied: bool = False, amount_supplied: bool = False): try: obj = _lock_batch_header(db, freight_id, ctx) expected_keys = _discover_batch_keys(db, freight_id, ctx) @@ -1112,10 +1125,25 @@ def update_freight(db: Session, freight_id: str, *, ctx: MarketFieldContext, use (row.sample_source, row.sample_id) not in visible for row in active ): raise PermissionError('仅当批次全部有效样本均在当前范围内时可修改批次信息') - if transport_mode not in (None, ''): - obj.transport_mode = resolve_transport_mode_value(db, tenant_no=ctx.tenant_no, raw=str(transport_mode), user_id=ctx.user_id) - if freight_amount not in (None, ''): - obj.freight_amount = _amount(freight_amount) + mode = obj.transport_mode or '' + if transport_mode is not None and str(transport_mode).strip() != '': + mode = resolve_transport_mode_value(db, tenant_no=ctx.tenant_no, raw=str(transport_mode), user_id=ctx.user_id) + plan = resolve_header_for_save( + transport_mode=mode, + existing_waybill=obj.waybill_no, + existing_amount=obj.freight_amount, + existing_settle_status=obj.settle_status, + waybill_supplied=waybill_supplied, + waybill_no=waybill_no, + amount_supplied=amount_supplied, + freight_amount=freight_amount, + ) + obj.transport_mode = plan.transport_mode + obj.waybill_no = plan.waybill_no + obj.freight_amount = plan.freight_amount + obj.settle_status = plan.settle_status + if plan.clear_settle_time: + obj.settle_time = None obj.update_by, obj.update_time = ctx.user_id, datetime.now() db.flush() db.commit() @@ -1145,6 +1173,7 @@ def link_samples(db: Session, freight_id: str, *, ctx: MarketFieldContext, user_ if not _freight_accessible(db, freight_id, ctx=ctx, user_info=user_info): raise HTTPException(status_code=404, detail='数据不存在或无权访问') obj = _lock_batch_header(db, freight_id, ctx) + assert_freight_mutable(obj.settle_status, 'items') expected_keys = _discover_batch_keys(db, freight_id, ctx) samples = _lock_sample_graph( db, sorted(set(expected_keys + keys)), ctx, user_info @@ -1185,6 +1214,7 @@ def unlink_samples(db: Session, freight_id: str, *, ctx: MarketFieldContext, use keys = _normalize_items(items) try: obj = _lock_batch_header(db, freight_id, ctx) + assert_freight_mutable(obj.settle_status, 'items') expected_keys = _discover_batch_keys(db, freight_id, ctx) visible = _lock_sample_graph(db, expected_keys, ctx, user_info) active = _lock_active_items( @@ -1233,6 +1263,7 @@ def delete_freight(db: Session, freight_id: str, *, ctx: MarketFieldContext, use """级联软删运费单;仅当全部有效关联样本均在当前范围内时允许。""" try: obj = _lock_batch_header(db, freight_id, ctx) + assert_freight_mutable(obj.settle_status, 'delete') expected_keys = _discover_batch_keys(db, freight_id, ctx) visible = _lock_sample_graph(db, expected_keys, ctx, user_info) active = _lock_active_items( diff --git a/backend/tests/test_market_freight_scope.py b/backend/tests/test_market_freight_scope.py index 494b7ba9..216d1b89 100644 --- a/backend/tests/test_market_freight_scope.py +++ b/backend/tests/test_market_freight_scope.py @@ -364,7 +364,10 @@ def test_header_update_allowed_when_all_active_items_are_visible(): db = _session() own, op = _sample(db, 'own') freight = _freight(db, 'own', [('cell_storage', own, op)]) - result = update_freight(db, freight.id, ctx=_ctx(), user_info=_user(), freight_amount='99.10') + result = update_freight( + db, freight.id, ctx=_ctx(), user_info=_user(), + freight_amount='99.10', amount_supplied=True, + ) assert result['freightAmount'] == '99.10' diff --git a/backend/tests/test_sf_express_settlement.py b/backend/tests/test_sf_express_settlement.py index f34880e4..40e9c89f 100644 --- a/backend/tests/test_sf_express_settlement.py +++ b/backend/tests/test_sf_express_settlement.py @@ -226,3 +226,36 @@ def test_sample_card_payload_keeps_null_amount(): assert numeric["freightAmount"] == "88.00" assert numeric["freightAmountLabel"] == "88.00" assert freight_amount_payload(Decimal("0"), "zxc", False)["freightAmount"] == 0.0 + + +def test_admin_and_market_bodies_accept_sf_without_amount(): + from app.api.sample_freight import SampleFreightCreateBody, SampleFreightUpdateBody + from app.schemas.market_mobile import ( + MarketFreightCreateBody, + MarketFreightUpdateBody, + MarketProtocolSampleFreightDraftBody, + ) + + admin = SampleFreightCreateBody.model_validate({ + "transportMode": "sfkd", + "waybillNo": "SF100", + "items": [{"sampleSource": "cell_storage", "sampleId": "s1"}], + }) + assert admin.freight_amount is None + assert admin.waybill_no == "SF100" + updated = SampleFreightUpdateBody.model_validate({"waybillNo": "SF101"}) + assert "waybill_no" in updated.model_fields_set + market = MarketFreightCreateBody.model_validate({ + "transportMode": "sfkd", + "waybillNo": "SF100", + "items": [{"sampleSource": "cell_storage", "sampleId": "s1"}], + }) + assert market.freight_amount is None + assert market.waybill_no == "SF100" + draft = MarketProtocolSampleFreightDraftBody.model_validate({ + "clientKey": "fd_1", + "transportMode": "sfkd", + "waybillNo": "SF100", + }) + assert draft.waybill_no == "SF100" + MarketFreightUpdateBody.model_validate({"waybillNo": "SF100"})