feat: 增加顺丰月结列表、结算和补填接口,市场端不能调用
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
7aa685840f
commit
41e8d6f7c4
3 files changed
+520
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user