Forráskód Böngészése

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

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 hete
szülő
commit
41e8d6f7c4

+ 123 - 0
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,

+ 199 - 0
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}

+ 198 - 0
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