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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-21 18:25:54 +08:00
1 parent d67965aae2
commit 11a46bf1eb
7 files changed
+155 -25

No files matched your search

+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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'
@@ -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"})