Browse Source

feat: 顺丰运费保存单号并清空金额,已结算批次禁止改关联

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 week ago
parent
commit
11a46bf1eb

+ 4 - 0
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='保存成功',
             )

+ 9 - 1
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:

+ 60 - 12
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,
         )

+ 5 - 2
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')

+ 40 - 9
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(

+ 4 - 1
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'
 
 

+ 33 - 0
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"})