feat: 增加顺丰月结列表、结算和补填接口,市场端不能调用

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-21 18:54:16 +08:00
1 parent 7aa685840f
commit 41e8d6f7c4
3 files changed
+520

No files matched your search

+123
View File
@@ -12,6 +12,7 @@ from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.crud import sample_freight as freight_svc
from app.crud import sf_express_settlement as sf_crud
from app.models.models import SysUser
router = APIRouter(prefix="/web/admin", tags=["样本运费批次"])
@@ -66,6 +67,19 @@ class SampleFreightItemsBody(BaseModel):
items: List[SampleFreightItemBody] = Field(default_factory=list)
class SfSettlementSettleBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
ids: List[str] = Field(default_factory=list)
freight_amount: Optional[float] = Field(None, alias="freightAmount")
class SfSettlementFillBody(BaseModel):
model_config = ConfigDict(extra="ignore", populate_by_name=True)
freight_amount: float = Field(..., alias="freightAmount")
@router.get("/sample-freights")
async def list_sample_freights(
page: int = Query(1, ge=1),
@@ -132,6 +146,115 @@ async def list_sample_freight_options(
return error_response(400, str(e))
@router.get("/sample-freights/sf-settlements")
async def list_sf_express_settlements(
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
waybillNo: Optional[str] = None,
settleStatus: Optional[str] = None,
createTimeFrom: Optional[str] = None,
createTimeTo: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
rows, total = sf_crud.list_sf_settlements(
db,
tenant_no=tenant_no,
user_info=user,
page=page,
page_size=pageSize,
waybill_no=waybillNo,
settle_status=settleStatus,
create_time_from=createTimeFrom,
create_time_to=createTimeTo,
)
return success_response(
{"list": rows, "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/sf-settlements/export")
async def export_sf_express_settlements(
waybillNo: Optional[str] = None,
settleStatus: Optional[str] = None,
createTimeFrom: Optional[str] = None,
createTimeTo: Optional[str] = None,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
rows = sf_crud.list_sf_settlements_for_export(
db,
tenant_no=tenant_no,
user_info=user,
waybill_no=waybillNo,
settle_status=settleStatus,
create_time_from=createTimeFrom,
create_time_to=createTimeTo,
)
return success_response({"list": rows})
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/sample-freights/sf-settlements/settle")
async def settle_sf_express_settlements(
body: SfSettlementSettleBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
data = sf_crud.settle_sf_freights(
db,
tenant_no=tenant_no,
user_id=user_id,
user_info=user,
ids=body.ids,
freight_amount=body.freight_amount,
)
return success_response(data, message="结算成功")
except PermissionError as e:
return error_response(403, str(e))
except ValueError as e:
return error_response(400, str(e))
@router.post("/sample-freights/sf-settlements/{freight_id}/fill-amount")
async def fill_sf_express_settlement_amount(
freight_id: str,
body: SfSettlementFillBody,
user: dict = Depends(require_admin_or_super_admin),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
data = sf_crud.fill_sf_freight_amount(
db,
tenant_no=tenant_no,
user_id=user_id,
user_info=user,
freight_id=freight_id,
freight_amount=body.freight_amount,
)
return success_response(data, message="补填成功")
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}")
async def get_sample_freight(
freight_id: str,
+199
View File
@@ -0,0 +1,199 @@
"""顺丰月结列表、结算、补填。只处理 transport_mode = sfkd。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from app.core.data_scope import apply_data_scope
from app.crud.sample_freight import _fmt_dt, _normalize_date_bound, _user_nick_name_map
from app.models.models import BizSampleFreight
from app.services.sf_express_settlement import (
SF_TRANSPORT_MODE,
SETTLE_SETTLED,
assert_settle_request,
freight_amount_label,
plan_fill_amount,
plan_settle,
)
def _base_query(db: Session, *, tenant_no: str, user_info: Optional[dict]):
query = db.query(BizSampleFreight).filter(
BizSampleFreight.tenant_no == tenant_no,
BizSampleFreight.del_flag == 0,
BizSampleFreight.transport_mode == SF_TRANSPORT_MODE,
)
return apply_data_scope(query, BizSampleFreight, user_info, db, tenant_no)
def _apply_filters(
query,
*,
waybill_no: Optional[str],
settle_status: Optional[str],
create_time_from: Optional[str],
create_time_to: Optional[str],
):
waybill = (waybill_no or "").strip()
if waybill:
query = query.filter(BizSampleFreight.waybill_no.like(f"%{waybill}%"))
status = (settle_status or "").strip()
if status:
query = query.filter(BizSampleFreight.settle_status == status)
if create_time_from and str(create_time_from).strip():
query = query.filter(
BizSampleFreight.create_time
>= _normalize_date_bound(str(create_time_from).strip())
)
if create_time_to and str(create_time_to).strip():
query = query.filter(
BizSampleFreight.create_time
<= _normalize_date_bound(str(create_time_to).strip(), end_of_day=True)
)
return query
def _to_row(obj: BizSampleFreight, create_by_name: str) -> Dict[str, Any]:
amount = obj.freight_amount
status = obj.settle_status or ""
return {
"id": obj.id,
"waybillNo": obj.waybill_no or "",
"createBy": obj.create_by or "",
"createByName": create_by_name or obj.create_by or "",
"createTime": _fmt_dt(obj.create_time),
"settleStatus": status,
"settleStatusLabel": "已结算" if status == SETTLE_SETTLED else "未结算",
"freightAmount": float(amount) if amount is not None else None,
"freightAmountLabel": freight_amount_label(obj.transport_mode, amount),
"settleTime": _fmt_dt(obj.settle_time) if obj.settle_time else "",
"transportMode": obj.transport_mode or "",
}
def list_sf_settlements(
db,
*,
tenant_no: str,
user_info: Optional[dict],
page: int = 1,
page_size: int = 10,
waybill_no: Optional[str] = None,
settle_status: Optional[str] = None,
create_time_from: Optional[str] = None,
create_time_to: Optional[str] = None,
) -> Tuple[List[Dict[str, Any]], int]:
page = max(1, int(page or 1))
page_size = max(1, min(100, int(page_size or 10)))
query = _apply_filters(
_base_query(db, tenant_no=tenant_no, user_info=user_info),
waybill_no=waybill_no,
settle_status=settle_status,
create_time_from=create_time_from,
create_time_to=create_time_to,
)
total = query.count()
rows = (
query.order_by(BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
names = _user_nick_name_map(db, [row.create_by for row in rows if row.create_by])
return [_to_row(row, names.get(row.create_by or "", "")) for row in rows], total
def list_sf_settlements_for_export(
db,
*,
tenant_no: str,
user_info: Optional[dict],
waybill_no: Optional[str] = None,
settle_status: Optional[str] = None,
create_time_from: Optional[str] = None,
create_time_to: Optional[str] = None,
) -> List[Dict[str, Any]]:
query = _apply_filters(
_base_query(db, tenant_no=tenant_no, user_info=user_info),
waybill_no=waybill_no,
settle_status=settle_status,
create_time_from=create_time_from,
create_time_to=create_time_to,
)
rows = query.order_by(
BizSampleFreight.create_time.desc(), BizSampleFreight.id.desc()
).all()
if not rows:
raise ValueError("没有可导出的数据")
names = _user_nick_name_map(db, [row.create_by for row in rows if row.create_by])
return [_to_row(row, names.get(row.create_by or "", "")) for row in rows]
def _lock_rows(
db, *, tenant_no: str, user_info: Optional[dict], ids: List[str]
) -> List[BizSampleFreight]:
wanted = [str(item or "").strip() for item in ids if str(item or "").strip()]
rows = (
_base_query(db, tenant_no=tenant_no, user_info=user_info)
.filter(BizSampleFreight.id.in_(wanted))
.all()
)
if len(rows) != len(set(wanted)):
raise ValueError("运费批次不存在")
return rows
def settle_sf_freights(
db,
*,
tenant_no: str,
user_id: Optional[str],
user_info: Optional[dict],
ids: List[str],
freight_amount: Any = None,
):
assert_settle_request(ids, freight_amount)
rows = _lock_rows(db, tenant_no=tenant_no, user_info=user_info, ids=ids)
now = datetime.now()
submitted = None if len(ids) != 1 else freight_amount
for obj in rows:
if obj.transport_mode != SF_TRANSPORT_MODE:
raise ValueError("只能结算顺丰快递运费")
if (obj.settle_status or "") == SETTLE_SETTLED:
raise ValueError("该运费已结算")
amount, status, settle_time = plan_settle(
current_amount=obj.freight_amount, submitted_amount=submitted, now=now
)
obj.freight_amount = amount
obj.settle_status = status
obj.settle_time = settle_time
obj.update_by = user_id
obj.update_time = now
db.commit()
return {"ids": [row.id for row in rows]}
def fill_sf_freight_amount(
db,
*,
tenant_no: str,
user_id: Optional[str],
user_info: Optional[dict],
freight_id: str,
freight_amount: Any,
):
rows = _lock_rows(db, tenant_no=tenant_no, user_info=user_info, ids=[freight_id])
obj = rows[0]
if obj.transport_mode != SF_TRANSPORT_MODE:
raise ValueError("只能补填顺丰快递运费")
obj.freight_amount = plan_fill_amount(
current_amount=obj.freight_amount,
settle_status=obj.settle_status,
submitted_amount=freight_amount,
)
obj.update_by = user_id
obj.update_time = datetime.now()
db.commit()
return {"id": obj.id}
+198
View File
@@ -385,3 +385,201 @@ def test_settled_protocol_save_relinks_same_count_to_new_sample():
assert live.protocol_id == "p1"
finally:
db.close()
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models.base import Base
from app.models.models import BizSampleFreight, SysUser
def _amount_equals(actual, expected: str) -> None:
"""SQLite 可能把 Numeric 读成 float,按数值比较,空金额必须保持 None。"""
assert actual is not None
assert Decimal(str(actual)) == Decimal(expected)
@pytest.fixture
def db():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
engine,
tables=[SysUser.__table__, BizSampleFreight.__table__],
)
session = sessionmaker(bind=engine)()
now = datetime(2026, 10, 21, 9, 0, 0)
common = dict(
tenant_no="tenant-a",
create_dept_id="d1",
create_time=now,
update_time=now,
del_flag=0,
)
session.add(
SysUser(
id="u1",
user_no="u1",
nick_name="张三",
email="",
phone_no="",
password="",
status="1",
dept_id="d1",
**common,
)
)
session.add_all(
[
BizSampleFreight(
id="sf-null",
freight_no="YF1",
transport_mode="sfkd",
freight_amount=None,
waybill_no="SF001",
settle_status="wjs",
create_by="u1",
**common,
),
BizSampleFreight(
id="sf-old",
freight_no="YF2",
transport_mode="sfkd",
freight_amount=Decimal("10.00"),
waybill_no="",
settle_status="wjs",
create_by="u1",
**common,
),
BizSampleFreight(
id="line",
freight_no="YF3",
transport_mode="zxc",
freight_amount=Decimal("5.00"),
create_by="u1",
**common,
),
BizSampleFreight(
id="other-tenant",
freight_no="YF4",
transport_mode="sfkd",
freight_amount=None,
waybill_no="SF999",
settle_status="wjs",
create_by="u1",
tenant_no="tenant-b",
create_dept_id="d1",
create_time=now,
update_time=now,
del_flag=0,
),
]
)
session.commit()
try:
yield session
finally:
session.close()
USER = {
"user_id": "u1",
"tenant_no": "tenant-a",
"role": "super_admin",
"dept_id": "d1",
}
def test_list_returns_only_current_tenant_sf_rows(db):
from app.crud.sf_express_settlement import list_sf_settlements
rows, total = list_sf_settlements(
db, tenant_no="tenant-a", user_info=USER, page=1, page_size=20
)
assert total == 2
assert {row["id"] for row in rows} == {"sf-null", "sf-old"}
assert all(row["transportMode"] == "sfkd" for row in rows)
null_row = next(row for row in rows if row["id"] == "sf-null")
assert null_row["freightAmountLabel"] == "月结"
assert null_row["createByName"] == "张三"
assert null_row["waybillNo"] == "SF001"
def test_settle_blank_keeps_null_and_historical_amount(db):
from app.crud.sf_express_settlement import settle_sf_freights
settle_sf_freights(
db,
tenant_no="tenant-a",
user_id="u1",
user_info=USER,
ids=["sf-null"],
freight_amount=None,
)
settle_sf_freights(
db,
tenant_no="tenant-a",
user_id="u1",
user_info=USER,
ids=["sf-old"],
freight_amount="",
)
assert db.query(BizSampleFreight).filter_by(id="sf-null").one().freight_amount is None
_amount_equals(
db.query(BizSampleFreight).filter_by(id="sf-old").one().freight_amount, "10"
)
assert db.query(BizSampleFreight).filter_by(id="sf-null").one().settle_status == "yjs"
with pytest.raises(ValueError, match="该运费已结算"):
settle_sf_freights(
db,
tenant_no="tenant-a",
user_id="u1",
user_info=USER,
ids=["sf-null"],
freight_amount=None,
)
def test_fill_amount_does_not_change_settle_time(db):
from app.crud.sf_express_settlement import fill_sf_freight_amount, settle_sf_freights
settle_sf_freights(
db,
tenant_no="tenant-a",
user_id="u1",
user_info=USER,
ids=["sf-null"],
freight_amount=None,
)
settled_at = db.query(BizSampleFreight).filter_by(id="sf-null").one().settle_time
fill_sf_freight_amount(
db,
tenant_no="tenant-a",
user_id="u1",
user_info=USER,
freight_id="sf-null",
freight_amount="15",
)
row = db.query(BizSampleFreight).filter_by(id="sf-null").one()
_amount_equals(row.freight_amount, "15.00")
assert row.settle_time == settled_at
def test_export_empty_filter_raises_chinese_message(db):
from app.crud.sf_express_settlement import list_sf_settlements_for_export
with pytest.raises(ValueError, match="没有可导出的数据"):
list_sf_settlements_for_export(
db, tenant_no="tenant-a", user_info=USER, waybill_no="不存在"
)
def test_market_router_has_no_settlement_paths():
from app.api.market_mobile import router as market_router
from app.api.sample_freight import router as admin_router
market_paths = [getattr(route, "path", "") for route in market_router.routes]
admin_paths = [getattr(route, "path", "") for route in admin_router.routes]
assert all("sf-settlement" not in path for path in market_paths)
assert "/web/admin/sample-freights/sf-settlements" in admin_paths
assert "/web/admin/sample-freights/sf-settlements/export" in admin_paths