From 41e8d6f7c44a46d32a6582ca7df5b8d5fe46465b Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 21 Oct 2026 18:54:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E9=A1=BA=E4=B8=B0?= =?UTF-8?q?=E6=9C=88=E7=BB=93=E5=88=97=E8=A1=A8=E3=80=81=E7=BB=93=E7=AE=97?= =?UTF-8?q?=E5=92=8C=E8=A1=A5=E5=A1=AB=E6=8E=A5=E5=8F=A3=EF=BC=8C=E5=B8=82?= =?UTF-8?q?=E5=9C=BA=E7=AB=AF=E4=B8=8D=E8=83=BD=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/app/api/sample_freight.py | 123 ++++++++++++ backend/app/crud/sf_express_settlement.py | 199 ++++++++++++++++++++ backend/tests/test_sf_express_settlement.py | 198 +++++++++++++++++++ 3 files changed, 520 insertions(+) create mode 100644 backend/app/crud/sf_express_settlement.py diff --git a/backend/app/api/sample_freight.py b/backend/app/api/sample_freight.py index 509f5172..0fff98c6 100644 --- a/backend/app/api/sample_freight.py +++ b/backend/app/api/sample_freight.py @@ -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, diff --git a/backend/app/crud/sf_express_settlement.py b/backend/app/crud/sf_express_settlement.py new file mode 100644 index 00000000..61b7eba9 --- /dev/null +++ b/backend/app/crud/sf_express_settlement.py @@ -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} diff --git a/backend/tests/test_sf_express_settlement.py b/backend/tests/test_sf_express_settlement.py index 23ea153f..88aa99bb 100644 --- a/backend/tests/test_sf_express_settlement.py +++ b/backend/tests/test_sf_express_settlement.py @@ -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