소스 검색

feat(pl): 运输计划模块接入数据权限过滤

新增 pl_data_scope 辅助,PL 列表/统计 apply_pl_scope,详情与改删 assert_pl_row_in_scope;运费结算待办审批 skip_scope。

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 4 달 전
부모
커밋
eff99edeea

+ 34 - 15
backend/app/api/pl/base_data.py

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
 
 from app.core.database import get_db
 from app.core.db_auth import DbUserContext, get_db_current_user
+from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
 from app.core.tenant_context import get_tenant_no
 from app.core.utils import generate_uuid
 from app.models.pl_models import (
@@ -77,6 +78,8 @@ def list_carriers(
             )
         )
 
+    query = apply_pl_scope(query, PlCarrier, user, db, tenant_no)
+
     query = query.order_by(
         case((PlCarrier.order_num.is_(None), 1), else_=0).asc(),
         PlCarrier.create_time.desc(),
@@ -116,7 +119,7 @@ def get_carrier(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> CarrierRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    carrier = _get_carrier(db, tenant_no, id)
+    carrier = _get_carrier(db, tenant_no, id, user)
     return carrier
 
 
@@ -128,7 +131,7 @@ def update_carrier(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> CarrierRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    carrier = _get_carrier(db, tenant_no, id)
+    carrier = _get_carrier(db, tenant_no, id, user)
 
     for key, value in payload.model_dump(exclude_unset=True).items():
         setattr(carrier, key, value)
@@ -146,13 +149,13 @@ def delete_carrier(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> None:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    carrier = _get_carrier(db, tenant_no, id)
+    carrier = _get_carrier(db, tenant_no, id, user)
     carrier.del_flag = 1
     carrier.update_by = user.id
     db.commit()
 
 
-def _get_carrier(db: Session, tenant_no: str, carrier_id: str) -> PlCarrier:
+def _get_carrier(db: Session, tenant_no: str, carrier_id: str, user: DbUserContext) -> PlCarrier:
     carrier = (
         db.query(PlCarrier)
         .filter(
@@ -164,6 +167,7 @@ def _get_carrier(db: Session, tenant_no: str, carrier_id: str) -> PlCarrier:
     )
     if not carrier:
         raise HTTPException(status_code=404, detail="承运商不存在")
+    assert_pl_row_in_scope(db, carrier, PlCarrier, user, tenant_no)
     return carrier
 
 
@@ -229,6 +233,8 @@ def list_vehicles(
     if q:
         query = query.filter(PlVehicle.license_plate.contains(q))
 
+    query = apply_pl_scope(query, PlVehicle, user, db, tenant_no)
+
     query = query.order_by(
         case((PlVehicle.order_num.is_(None), 1), else_=0).asc(),
         PlVehicle.create_time.desc(),
@@ -269,6 +275,7 @@ def create_vehicle(
         carrier_id=payload.carrier_id,
         carrier_name=payload.carrier_name,
         license_plate=payload.license_plate,
+        trailer_no=payload.trailer_no,
         load_capacity=payload.load_capacity,
         vehicle_status=payload.vehicle_status,
         remark=payload.remark,
@@ -315,7 +322,7 @@ def get_vehicle(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> VehicleDetailRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    vehicle = _get_vehicle(db, tenant_no, id)
+    vehicle = _get_vehicle(db, tenant_no, id, user)
     return _vehicle_to_detail(db, tenant_no, vehicle)
 
 
@@ -327,7 +334,7 @@ def update_vehicle(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> VehicleDetailRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    vehicle = _get_vehicle(db, tenant_no, id)
+    vehicle = _get_vehicle(db, tenant_no, id, user)
 
     personnel_list = payload.personnel_list
     cert_list = payload.cert_list
@@ -379,13 +386,13 @@ def delete_vehicle(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> None:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    vehicle = _get_vehicle(db, tenant_no, id)
+    vehicle = _get_vehicle(db, tenant_no, id, user)
     vehicle.del_flag = 1
     vehicle.update_by = user.id
     db.commit()
 
 
-def _get_vehicle(db: Session, tenant_no: str, vehicle_id: str) -> PlVehicle:
+def _get_vehicle(db: Session, tenant_no: str, vehicle_id: str, user: DbUserContext) -> PlVehicle:
     vehicle = (
         db.query(PlVehicle)
         .filter(
@@ -397,6 +404,7 @@ def _get_vehicle(db: Session, tenant_no: str, vehicle_id: str) -> PlVehicle:
     )
     if not vehicle:
         raise HTTPException(status_code=404, detail="车辆不存在")
+    assert_pl_row_in_scope(db, vehicle, PlVehicle, user, tenant_no)
     return vehicle
 
 
@@ -451,6 +459,8 @@ def list_personnel(
             )
         )
 
+    query = apply_pl_scope(query, PlPersonnel, user, db, tenant_no)
+
     query = query.order_by(
         case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(),
         PlPersonnel.create_time.desc(),
@@ -508,7 +518,7 @@ def get_personnel(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> PersonnelDetailRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    personnel = _get_personnel(db, tenant_no, id)
+    personnel = _get_personnel(db, tenant_no, id, user)
     return _personnel_to_detail(db, tenant_no, personnel)
 
 
@@ -520,7 +530,7 @@ def update_personnel(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> PersonnelDetailRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    personnel = _get_personnel(db, tenant_no, id)
+    personnel = _get_personnel(db, tenant_no, id, user)
 
     cert_list = payload.cert_list
     data = payload.model_dump(exclude_unset=True, exclude={"cert_list"})
@@ -557,13 +567,13 @@ def delete_personnel(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> None:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    personnel = _get_personnel(db, tenant_no, id)
+    personnel = _get_personnel(db, tenant_no, id, user)
     personnel.del_flag = 1
     personnel.update_by = user.id
     db.commit()
 
 
-def _get_personnel(db: Session, tenant_no: str, personnel_id: str) -> PlPersonnel:
+def _get_personnel(db: Session, tenant_no: str, personnel_id: str, user: DbUserContext) -> PlPersonnel:
     personnel = (
         db.query(PlPersonnel)
         .filter(
@@ -575,6 +585,7 @@ def _get_personnel(db: Session, tenant_no: str, personnel_id: str) -> PlPersonne
     )
     if not personnel:
         raise HTTPException(status_code=404, detail="人员不存在")
+    assert_pl_row_in_scope(db, personnel, PlPersonnel, user, tenant_no)
     return personnel
 
 
@@ -621,6 +632,8 @@ def list_drivers(
             )
         )
 
+    query = apply_pl_scope(query, PlPersonnel, user, db, tenant_no)
+
     query = query.order_by(
         case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(),
         PlPersonnel.create_time.desc(),
@@ -660,7 +673,7 @@ def get_driver(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> DriverRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    personnel = _get_personnel(db, tenant_no, id)
+    personnel = _get_personnel(db, tenant_no, id, user)
     return _personnel_to_driver_read(personnel)
 
 
@@ -672,7 +685,7 @@ def update_driver(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> DriverRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    personnel = _get_personnel(db, tenant_no, id)
+    personnel = _get_personnel(db, tenant_no, id, user)
 
     if payload.driver_name is not None:
         personnel.personnel_name = payload.driver_name
@@ -696,7 +709,7 @@ def delete_driver(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> None:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    personnel = _get_personnel(db, tenant_no, id)
+    personnel = _get_personnel(db, tenant_no, id, user)
     personnel.del_flag = 1
     personnel.update_by = user.id
     db.commit()
@@ -714,6 +727,8 @@ def bind_vehicle_driver(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> dict:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    _get_vehicle(db, tenant_no, payload.vehicle_id, user)
+    _get_personnel(db, tenant_no, payload.driver_id, user)
 
     if payload.is_default == 1:
         db.query(PlVehiclePersonnel).filter(
@@ -764,5 +779,9 @@ def unbind_vehicle_driver(
             .first()
         )
     if vd:
+        if isinstance(vd, PlVehiclePersonnel):
+            assert_pl_row_in_scope(db, vd, PlVehiclePersonnel, user, tenant_no)
+        else:
+            assert_pl_row_in_scope(db, vd, PlVehicleDriver, user, tenant_no)
         vd.del_flag = 1
         db.commit()

+ 24 - 8
backend/app/api/pl/freight.py

@@ -47,6 +47,7 @@ def _generate_settlement_no(db: Session) -> str:
 @router.get("/unsettled-plans", response_model=UnsettledPlanListResponse)
 def list_unsettled_plans(
     q: Optional[str] = Query(default=None),
+    carrier_id: Optional[str] = Query(default=None, alias="carrierId"),
     plan_type: Optional[str] = Query(default=None, alias="planType"),
     license_plate: Optional[str] = Query(default=None, alias="licensePlate"),
     page: int = Query(default=1, ge=1),
@@ -58,7 +59,9 @@ def list_unsettled_plans(
     total, items = freight_svc.list_unsettled_plans(
         db,
         tenant_no=tenant_no,
+        user=user,
         q=q,
+        carrier_id=carrier_id,
         plan_type=plan_type,
         license_plate=license_plate,
         page=page,
@@ -70,8 +73,12 @@ def list_unsettled_plans(
 @router.get("/", response_model=FreightSettlementListResponse)
 def list_settlements(
     q: Optional[str] = Query(default=None),
+    carrier_id: Optional[str] = Query(default=None, alias="carrierId"),
     settlement_status: Optional[str] = Query(default=None, alias="settlementStatus"),
-    tab: Optional[str] = Query(default=None, description="bills|disburse|settled"),
+    tab: Optional[str] = Query(
+        default=None,
+        description="bills|approval_pending|approval_done|disburse|settled",
+    ),
     page: int = Query(default=1, ge=1),
     page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
     db: Session = Depends(get_db),
@@ -82,7 +89,9 @@ def list_settlements(
         db,
         tenant_no=tenant_no,
         user_id=user.id,
+        user=user,
         q=q,
+        carrier_id=carrier_id,
         settlement_status=settlement_status,
         tab=tab,
         page=page,
@@ -118,7 +127,7 @@ def get_settlement(
 ) -> FreightSettlementWithTransports:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
     detail = freight_svc.get_settlement_detail(
-        db, tenant_no=tenant_no, settlement_id=id, user_id=user.id
+        db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user
     )
     return FreightSettlementWithTransports.model_validate(detail)
 
@@ -132,7 +141,12 @@ def update_settlement(
 ) -> FreightSettlementRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
     return freight_svc.update_settlement_draft(
-        db, tenant_no=tenant_no, settlement_id=id, payload=payload, user_id=user.id
+        db,
+        tenant_no=tenant_no,
+        settlement_id=id,
+        payload=payload,
+        user_id=user.id,
+        user=user,
     )
 
 
@@ -144,7 +158,7 @@ def delete_settlement(
 ) -> None:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
     freight_svc.delete_draft_settlement(
-        db, tenant_no=tenant_no, settlement_id=id, user_id=user.id
+        db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user
     )
 
 
@@ -163,6 +177,7 @@ def submit_settlement_approval(
         body=payload,
         user_id=user.id,
         dept_id=user.dept_id,
+        user=user,
     )
 
 
@@ -194,7 +209,7 @@ def withdraw_settlement_approval(
 ) -> FreightSettlementRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
     return freight_svc.withdraw_settlement_approval(
-        db, tenant_no=tenant_no, settlement_id=id, user_id=user.id
+        db, tenant_no=tenant_no, settlement_id=id, user_id=user.id, user=user
     )
 
 
@@ -205,7 +220,7 @@ def get_settlement_approval_flow(
     user: DbUserContext = Depends(get_db_current_user),
 ):
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    freight_svc.get_settlement_row(db, tenant_no, id)
+    freight_svc.get_settlement_row(db, tenant_no, id, user=user)
     return freight_svc.get_approval_flow(db, id)
 
 
@@ -224,6 +239,7 @@ def disburse_settlement(
         body=payload,
         user_id=user.id,
         dept_id=user.dept_id,
+        user=user,
     )
 
 
@@ -253,8 +269,8 @@ def list_vouchers(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> list[RemittanceVoucherRead]:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    freight_svc.get_settlement_row(db, tenant_no, settlement_id)
+    freight_svc.get_settlement_row(db, tenant_no, settlement_id, user=user)
     detail = freight_svc.get_settlement_detail(
-        db, tenant_no=tenant_no, settlement_id=settlement_id, user_id=user.id
+        db, tenant_no=tenant_no, settlement_id=settlement_id, user_id=user.id, user=user
     )
     return detail.get("vouchers") or []

+ 11 - 0
backend/app/api/pl/inventory.py

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
 
 from app.core.database import get_db
 from app.core.db_auth import DbUserContext, get_db_current_user
+from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
 from app.core.tenant_context import get_tenant_no
 from app.core.utils import generate_uuid
 from app.models.pl_models import (
@@ -61,6 +62,8 @@ def get_inventory_ledger(
     if product_name:
         query = query.filter(PlInventory.product_name.contains(product_name))
 
+    query = apply_pl_scope(query, PlInventory, user, db, tenant_no)
+
     total = query.count()
     offset = (page - 1) * page_size
     items = query.order_by(PlInventory.product_name).offset(offset).limit(page_size).all()
@@ -92,6 +95,7 @@ def set_warning_threshold(
     )
 
     if inventory:
+        assert_pl_row_in_scope(db, inventory, PlInventory, user, tenant_no)
         inventory.low_stock_warning = payload.low_stock_warning
         inventory.high_stock_warning = payload.high_stock_warning
     else:
@@ -136,6 +140,7 @@ def list_warnings(
     if status:
         query = query.filter(PlInventoryWarning.status == status)
 
+    query = apply_pl_scope(query, PlInventoryWarning, user, db, tenant_no)
     return query.order_by(PlInventoryWarning.create_time.desc()).all()
 
 
@@ -160,6 +165,7 @@ def update_warning(
 
     if not warning:
         raise HTTPException(status_code=404, detail="预警不存在")
+    assert_pl_row_in_scope(db, warning, PlInventoryWarning, user, tenant_no)
 
     if payload.status == "resolved":
         warning.status = "resolved"
@@ -207,6 +213,8 @@ def list_transactions(
     if date_to:
         query = query.filter(PlInventoryTransaction.create_time <= date_to)
 
+    query = apply_pl_scope(query, PlInventoryTransaction, user, db, tenant_no)
+
     total = query.count()
     offset = (page - 1) * page_size
     items = query.order_by(PlInventoryTransaction.create_time.desc()).offset(offset).limit(page_size).all()
@@ -236,6 +244,7 @@ def get_summary(
         PlInventoryTransaction.create_time >= date_from,
         PlInventoryTransaction.create_time <= date_to,
     )
+    query = apply_pl_scope(query, PlInventoryTransaction, user, db, tenant_no)
 
     if product_id:
         query = query.filter(PlInventoryTransaction.product_id == product_id)
@@ -295,6 +304,7 @@ def list_document_reminders(
     if status:
         query = query.filter(PlDocumentReminder.status == status)
 
+    query = apply_pl_scope(query, PlDocumentReminder, user, db, tenant_no)
     return query.order_by(PlDocumentReminder.expire_date.asc()).all()
 
 
@@ -319,6 +329,7 @@ def update_document_reminder(
 
     if not reminder:
         raise HTTPException(status_code=404, detail="提醒不存在")
+    assert_pl_row_in_scope(db, reminder, PlDocumentReminder, user, tenant_no)
 
     reminder.status = payload.status
     if payload.remark:

+ 14 - 10
backend/app/api/pl/plan.py

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
 from app.core.config import settings
 from app.core.database import get_db
 from app.core.db_auth import DbUserContext, get_db_current_user
+from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
 from app.core.tenant_context import get_tenant_no
 from app.core.utils import generate_uuid
 from app.models.crm_models import CrmContract, CrmContractProduct, CrmProduct
@@ -171,6 +172,8 @@ def list_plans(
     if plan_date_to:
         query = query.filter(PlPlan.plan_date <= plan_date_to)
 
+    query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
+
     # 统计总数
     total = query.count()
 
@@ -234,9 +237,8 @@ def count_plans_by_contract(
             PlPlan.del_flag == 0,
             PlPlan.contract_id.in_(ids),
         )
-        .group_by(PlPlan.contract_id)
-        .all()
     )
+    rows = apply_pl_scope(rows, PlPlan, user, db, tenant_no).group_by(PlPlan.contract_id).all()
     return {"counts": {contract_id: count for contract_id, count in rows}}
 
 
@@ -544,7 +546,7 @@ def get_plan(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> PlanWithProducts:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    plan = _get_plan(db, tenant_no, id)
+    plan = _get_plan(db, tenant_no, id, user)
 
     products = (
         db.query(PlPlanProduct)
@@ -588,7 +590,7 @@ def update_plan(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> PlanRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    plan = _get_plan(db, tenant_no, id)
+    plan = _get_plan(db, tenant_no, id, user)
 
     # 更新主表字段
     update_data = payload.model_dump(exclude_unset=True, exclude={"products", "transport"})
@@ -698,6 +700,7 @@ def patch_plan_product_certs(
 ) -> PlanWithProducts:
     """更新计划危化品产品购买证/运输证(不触发安排运输)"""
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
+    _get_plan(db, tenant_no, id, user)
     update_plan_product_certs(
         db,
         tenant_no=tenant_no,
@@ -705,7 +708,7 @@ def patch_plan_product_certs(
         items=payload.products,
         user_id=user.id,
     )
-    plan = _get_plan(db, tenant_no, id)
+    plan = _get_plan(db, tenant_no, id, user)
     products = (
         db.query(PlPlanProduct)
         .filter(
@@ -733,7 +736,7 @@ def get_plan_arrange_info(
 ) -> PlanArrangeInfoRead:
     """获取计划已安排运输后的司机链接与地磅出入库关联(供查看弹窗复制简述/二维码)。"""
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    plan = _get_plan(db, tenant_no, id)
+    plan = _get_plan(db, tenant_no, id, user)
 
     empty = PlanArrangeInfoRead(driver_url=None, license_plate=None, site_records=[], inventory_records=[])
     if plan.follow_status == "cancelled" or plan.transport_status != "arranged" or not plan.transport_id:
@@ -847,7 +850,7 @@ def cancel_plan(
 ) -> PlanRead:
     """作废运输计划:取消跟进状态、作废关联地磅出入库单、取消运输单。"""
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    plan = _get_plan(db, tenant_no, id)
+    plan = _get_plan(db, tenant_no, id, user)
 
     if plan.follow_status == "cancelled":
         raise HTTPException(status_code=400, detail="计划已作废")
@@ -905,7 +908,7 @@ def archive_plan(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> PlanWithProducts:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    plan = _get_plan(db, tenant_no, id)
+    plan = _get_plan(db, tenant_no, id, user)
 
     if plan.follow_status == "cancelled":
         raise HTTPException(status_code=400, detail="已作废的计划不能归档")
@@ -965,7 +968,7 @@ def delete_plan(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> None:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    plan = _get_plan(db, tenant_no, id)
+    plan = _get_plan(db, tenant_no, id, user)
 
     plan.del_flag = 1
     plan.update_by = user.id
@@ -990,7 +993,7 @@ def delete_plan(
 # =============================================================================
 
 
-def _get_plan(db: Session, tenant_no: str, plan_id: str) -> PlPlan:
+def _get_plan(db: Session, tenant_no: str, plan_id: str, user: DbUserContext) -> PlPlan:
     plan = (
         db.query(PlPlan)
         .filter(
@@ -1002,4 +1005,5 @@ def _get_plan(db: Session, tenant_no: str, plan_id: str) -> PlPlan:
     )
     if not plan:
         raise HTTPException(status_code=404, detail="计划不存在")
+    assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
     return plan

+ 10 - 4
backend/app/api/pl/transport.py

@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
 
 from app.core.database import get_db
 from app.core.db_auth import DbUserContext, get_db_current_user
+from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
 from app.core.tenant_context import get_tenant_no
 from app.core.utils import generate_uuid
 from app.core.config import settings
@@ -334,6 +335,8 @@ def list_transports(
     if driver_id:
         query = query.filter(PlTransport.driver_id == driver_id)
 
+    query = apply_pl_scope(query, PlTransport, user, db, tenant_no)
+
     total = query.count()
     offset = (page - 1) * page_size
     transports = query.order_by(PlTransport.create_time.desc()).offset(offset).limit(page_size).all()
@@ -401,6 +404,7 @@ def create_transport(
             # 更新计划关联的运输信息
             plan = db.query(PlPlan).filter(PlPlan.id == pid, PlPlan.del_flag == 0).first()
             if plan:
+                assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
                 plan.transport_id = transport.id
                 plan.transport_no = transport_no
                 plan.vehicle_id = transport.vehicle_id
@@ -435,6 +439,7 @@ def arrange_transport(
     ).first()
     if not plan:
         raise HTTPException(status_code=404, detail="计划不存在")
+    assert_pl_row_in_scope(db, plan, PlPlan, user, tenant_no)
 
     # 获取车辆信息
     vehicle_info = _get_vehicle_info(db, payload.vehicle_id, tenant_no)
@@ -655,7 +660,7 @@ def get_transport(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> TransportRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    transport = _get_transport(db, tenant_no, id)
+    transport = _get_transport(db, tenant_no, id, user)
     return transport
 
 
@@ -681,7 +686,7 @@ def generate_qrcode(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> QrCodeResponse:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    transport = _get_transport(db, tenant_no, id)
+    transport = _get_transport(db, tenant_no, id, user)
 
     from datetime import timedelta
     expire_date = date.today() + timedelta(days=expire_days)
@@ -755,7 +760,7 @@ def submit_approval(
     user: DbUserContext = Depends(get_db_current_user),
 ) -> TransportRead:
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    transport = _get_transport(db, tenant_no, id)
+    transport = _get_transport(db, tenant_no, id, user)
 
     if transport.approval_status not in ("none", "rejected"):
         raise HTTPException(status_code=400, detail="当前状态不允许提交审批")
@@ -880,7 +885,7 @@ def get_transport_approvals(
 # =============================================================================
 
 
-def _get_transport(db: Session, tenant_no: str, transport_id: str) -> PlTransport:
+def _get_transport(db: Session, tenant_no: str, transport_id: str, user: DbUserContext) -> PlTransport:
     transport = (
         db.query(PlTransport)
         .filter(
@@ -892,4 +897,5 @@ def _get_transport(db: Session, tenant_no: str, transport_id: str) -> PlTranspor
     )
     if not transport:
         raise HTTPException(status_code=404, detail="运输不存在")
+    assert_pl_row_in_scope(db, transport, PlTransport, user, tenant_no)
     return transport

+ 27 - 0
backend/app/services/pl_data_scope.py

@@ -0,0 +1,27 @@
+"""PL 模块数据权限辅助(create_by / create_dept_id,无 owner_field)。"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from sqlalchemy.orm import Session
+
+from app.core.data_scope import apply_data_scope, assert_row_in_data_scope
+
+if TYPE_CHECKING:
+    from app.core.db_auth import DbUserContext
+
+
+def apply_pl_scope(query, model, user: "DbUserContext", db: Session, tenant_no: str):
+    """列表查询应用数据权限。"""
+    return apply_data_scope(query, model, user.to_user_info(), db, tenant_no)
+
+
+def assert_pl_row_in_scope(
+    db: Session,
+    row,
+    model,
+    user: "DbUserContext",
+    tenant_no: str,
+) -> None:
+    """详情/改/删前校验行级数据权限。"""
+    assert_row_in_data_scope(db, row, model, user.to_user_info(), tenant_no)

+ 191 - 16
backend/app/services/pl_freight_settlement_service.py

@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
 
 from app.core.utils import generate_uuid
 from app.models.models import SysUser
+from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope
 from app.models.pl_models import (
     PlFreightSettlement,
     PlFreightSettlementApproval,
@@ -29,7 +30,9 @@ from app.schemas.pl.freight import (
 _FMT = "%Y-%m-%d %H:%M:%S"
 
 TAB_STATUS_MAP = {
-    "bills": ("pending", "approving"),
+    "bills": ("pending", "approving", "approved", "confirmed"),
+    "approval_pending": ("approving",),
+    "approval_done": ("approved", "confirmed"),
     "disburse": ("approved",),
     "settled": ("confirmed",),
 }
@@ -61,7 +64,14 @@ def _occupied_transport_ids_subquery(db: Session, tenant_no: str):
     )
 
 
-def get_settlement_row(db: Session, tenant_no: str, settlement_id: str) -> PlFreightSettlement:
+def get_settlement_row(
+    db: Session,
+    tenant_no: str,
+    settlement_id: str,
+    *,
+    user=None,
+    skip_scope: bool = False,
+) -> PlFreightSettlement:
     row = (
         db.query(PlFreightSettlement)
         .filter(
@@ -73,6 +83,8 @@ def get_settlement_row(db: Session, tenant_no: str, settlement_id: str) -> PlFre
     )
     if not row:
         raise HTTPException(status_code=404, detail="结算单不存在")
+    if user and not skip_scope:
+        assert_pl_row_in_scope(db, row, PlFreightSettlement, user, tenant_no)
     return row
 
 
@@ -80,7 +92,9 @@ def list_unsettled_plans(
     db: Session,
     *,
     tenant_no: str,
+    user=None,
     q: Optional[str] = None,
+    carrier_id: Optional[str] = None,
     plan_type: Optional[str] = None,
     license_plate: Optional[str] = None,
     page: int = 1,
@@ -112,11 +126,16 @@ def list_unsettled_plans(
                 PlPlan.supplier_name.contains(q),
             )
         )
+    if carrier_id:
+        query = query.filter(PlTransport.carrier_id == carrier_id)
     if plan_type:
         query = query.filter(PlPlan.plan_type == plan_type)
     if license_plate:
         query = query.filter(PlPlan.license_plate.contains(license_plate))
 
+    if user:
+        query = apply_pl_scope(query, PlPlan, user, db, tenant_no)
+
     total = query.count()
     rows = (
         query.order_by(PlPlan.update_time.desc())
@@ -238,8 +257,10 @@ def create_settlement(
     return settlement
 
 
-def delete_draft_settlement(db: Session, *, tenant_no: str, settlement_id: str, user_id: str) -> None:
-    settlement = get_settlement_row(db, tenant_no, settlement_id)
+def delete_draft_settlement(
+    db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
+) -> None:
+    settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
     if settlement.settlement_status != "pending":
         raise HTTPException(status_code=400, detail="仅草稿结算单可删除")
 
@@ -363,6 +384,84 @@ def create_settlement_approval_records(
     db.flush()
 
 
+def _clear_settlement_approval_records(db: Session, settlement_id: str) -> None:
+    """软删除结算单历史审批记录(重新提交审批前清理)"""
+    now = datetime.now()
+    rows = (
+        db.query(PlFreightSettlementApproval)
+        .filter(
+            PlFreightSettlementApproval.settlement_id == settlement_id,
+            PlFreightSettlementApproval.del_flag == 0,
+        )
+        .all()
+    )
+    for row in rows:
+        row.del_flag = 1
+        row.update_time = now
+    if rows:
+        db.flush()
+
+
+def _sync_settlement_status_if_approval_complete(
+    db: Session, settlement: PlFreightSettlement
+) -> bool:
+    """审批记录已全部完成但结算单仍停留在 approving 时自动纠正(兼容历史脏数据)"""
+    if settlement.settlement_status != "approving":
+        return False
+    if not _settlement_all_approved(db, settlement.id):
+        return False
+    settlement.settlement_status = "approved"
+    settlement.update_time = datetime.now()
+    return True
+
+
+def _sync_settlement_status_if_disbursed(
+    db: Session, settlement: PlFreightSettlement
+) -> bool:
+    """已有汇款凭证但结算单仍停留在 approved 时自动纠正(兼容历史脏数据)"""
+    if settlement.settlement_status != "approved":
+        return False
+    voucher = (
+        db.query(PlRemittanceVoucher)
+        .filter(
+            PlRemittanceVoucher.settlement_id == settlement.id,
+            PlRemittanceVoucher.del_flag == 0,
+        )
+        .first()
+    )
+    if not voucher:
+        return False
+    settlement.settlement_status = "confirmed"
+    settlement.update_time = datetime.now()
+    return True
+
+
+def _sync_disbursed_settlements(db: Session, tenant_no: str) -> None:
+    """批量纠正已有汇款凭证但仍为 approved 的结算单"""
+    rows = (
+        db.query(PlFreightSettlement)
+        .filter(
+            PlFreightSettlement.tenant_no == tenant_no,
+            PlFreightSettlement.del_flag == 0,
+            PlFreightSettlement.settlement_status == "approved",
+            db.query(PlRemittanceVoucher.id)
+            .filter(
+                PlRemittanceVoucher.settlement_id == PlFreightSettlement.id,
+                PlRemittanceVoucher.del_flag == 0,
+            )
+            .exists(),
+        )
+        .all()
+    )
+    if not rows:
+        return
+    now = datetime.now()
+    for row in rows:
+        row.settlement_status = "confirmed"
+        row.update_time = now
+    db.commit()
+
+
 def can_user_approve_settlement(db: Session, settlement_id: str, user_id: str) -> bool:
     if not settlement_id or not user_id:
         return False
@@ -413,6 +512,20 @@ def _advance_after_approval(
     if result == "rejected":
         settlement.settlement_status = "pending"
         settlement.update_time = now
+        others = (
+            db.query(PlFreightSettlementApproval)
+            .filter(
+                PlFreightSettlementApproval.settlement_id == settlement.id,
+                PlFreightSettlementApproval.id != current.id,
+                PlFreightSettlementApproval.approval_result == "pending",
+                PlFreightSettlementApproval.del_flag == 0,
+            )
+            .all()
+        )
+        for row in others:
+            row.approval_result = "skipped"
+            row.is_current = 0
+            row.update_time = now
         return
 
     approval_type = current.approval_type or "sequential"
@@ -477,8 +590,9 @@ def submit_settlement_approval(
     body: FreightSettlementSubmitApprovalBody,
     user_id: str,
     dept_id: str,
+    user=None,
 ) -> PlFreightSettlement:
-    settlement = get_settlement_row(db, tenant_no, settlement_id)
+    settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
     if settlement.settlement_status != "pending":
         raise HTTPException(status_code=400, detail="仅草稿状态可提交审批")
 
@@ -488,6 +602,8 @@ def submit_settlement_approval(
     settlement.update_by = user_id
     db.flush()
 
+    _clear_settlement_approval_records(db, settlement_id)
+
     create_settlement_approval_records(
         db,
         tenant_no=tenant_no,
@@ -523,7 +639,7 @@ def process_settlement_approval(
     if not approval:
         raise HTTPException(status_code=404, detail="审批记录不存在")
 
-    settlement = get_settlement_row(db, tenant_no, approval.settlement_id)
+    settlement = get_settlement_row(db, tenant_no, approval.settlement_id, skip_scope=True)
     if settlement.settlement_status != "approving":
         raise HTTPException(status_code=400, detail="结算单不在审批中")
 
@@ -541,6 +657,8 @@ def process_settlement_approval(
     approval.update_by = user_id
     approval.update_time = now
 
+    # SessionLocal 关闭了 autoflush,须先 flush 再查询 pending 计数,否则当前审批仍会被算作 pending
+    db.flush()
     _advance_after_approval(db, settlement, approval, result)
     settlement.update_by = user_id
     db.commit()
@@ -549,9 +667,9 @@ def process_settlement_approval(
 
 
 def withdraw_settlement_approval(
-    db: Session, *, tenant_no: str, settlement_id: str, user_id: str
+    db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
 ) -> PlFreightSettlement:
-    settlement = get_settlement_row(db, tenant_no, settlement_id)
+    settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
     if settlement.settlement_status != "approving":
         raise HTTPException(status_code=400, detail="仅审批中可撤回")
     if settlement.create_by != user_id:
@@ -586,8 +704,9 @@ def disburse_settlement(
     body: FreightSettlementDisburseBody,
     user_id: str,
     dept_id: str,
+    user=None,
 ) -> PlFreightSettlement:
-    settlement = get_settlement_row(db, tenant_no, settlement_id)
+    settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
     if settlement.settlement_status != "approved":
         raise HTTPException(status_code=400, detail="仅待发放状态可登记")
 
@@ -710,6 +829,7 @@ def enrich_settlement_list_item(
         "invoiceAmount": settlement.invoice_amount,
         "invoiceNo": settlement.invoice_no,
         "invoiceDate": settlement.invoice_date,
+        "invoiceAttachmentUrls": settlement.invoice_attachment_urls,
         "settlementStatus": settlement.settlement_status,
         "remark": settlement.remark,
         "tenantNo": settlement.tenant_no,
@@ -739,25 +859,49 @@ def list_settlements_enriched(
     *,
     tenant_no: str,
     user_id: str,
+    user=None,
     q: Optional[str] = None,
+    carrier_id: Optional[str] = None,
     settlement_status: Optional[str] = None,
     tab: Optional[str] = None,
     page: int = 1,
     page_size: int = 20,
 ) -> Tuple[int, List[Dict[str, Any]]]:
+    _sync_disbursed_settlements(db, tenant_no)
     query = db.query(PlFreightSettlement).filter(
         PlFreightSettlement.tenant_no == tenant_no,
         PlFreightSettlement.del_flag == 0,
     )
 
     if tab and tab in TAB_STATUS_MAP:
-        query = query.filter(PlFreightSettlement.settlement_status.in_(TAB_STATUS_MAP[tab]))
+        tab_statuses = TAB_STATUS_MAP[tab]
+        if settlement_status and settlement_status in tab_statuses:
+            query = query.filter(PlFreightSettlement.settlement_status == settlement_status)
+        else:
+            query = query.filter(PlFreightSettlement.settlement_status.in_(tab_statuses))
     elif settlement_status:
         query = query.filter(PlFreightSettlement.settlement_status == settlement_status)
 
     if q:
         query = query.filter(PlFreightSettlement.settlement_no.contains(q))
 
+    if carrier_id:
+        carrier_settlement_ids = (
+            db.query(PlFreightSettlementTransport.settlement_id)
+            .join(PlTransport, PlTransport.id == PlFreightSettlementTransport.transport_id)
+            .filter(
+                PlFreightSettlementTransport.tenant_no == tenant_no,
+                PlFreightSettlementTransport.del_flag == 0,
+                PlTransport.del_flag == 0,
+                PlTransport.carrier_id == carrier_id,
+            )
+            .distinct()
+        )
+        query = query.filter(PlFreightSettlement.id.in_(carrier_settlement_ids))
+
+    if user and tab != "approval_pending":
+        query = apply_pl_scope(query, PlFreightSettlement, user, db, tenant_no)
+
     total = query.count()
     rows = (
         query.order_by(PlFreightSettlement.create_time.desc())
@@ -765,13 +909,34 @@ def list_settlements_enriched(
         .limit(page_size)
         .all()
     )
+    status_fixed = False
+    for row in rows:
+        if _sync_settlement_status_if_approval_complete(db, row):
+            status_fixed = True
+        if _sync_settlement_status_if_disbursed(db, row):
+            status_fixed = True
+    if status_fixed:
+        db.commit()
+        for row in rows:
+            db.refresh(row)
+
+    if tab == "bills":
+        bills_statuses = set(TAB_STATUS_MAP["bills"])
+        rows = [r for r in rows if r.settlement_status in bills_statuses]
+
     return total, [enrich_settlement_list_item(db, r, user_id) for r in rows]
 
 
 def get_settlement_detail(
-    db: Session, *, tenant_no: str, settlement_id: str, user_id: str
+    db: Session, *, tenant_no: str, settlement_id: str, user_id: str, user=None
 ) -> Dict[str, Any]:
-    settlement = get_settlement_row(db, tenant_no, settlement_id)
+    settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
+    if _sync_settlement_status_if_approval_complete(db, settlement):
+        db.commit()
+        db.refresh(settlement)
+    if _sync_settlement_status_if_disbursed(db, settlement):
+        db.commit()
+        db.refresh(settlement)
     base = enrich_settlement_list_item(db, settlement, user_id)
 
     transport_ids = _get_transport_ids(db, settlement_id)
@@ -812,9 +977,12 @@ def get_settlement_detail(
                 "transportNo": t.transport_no,
                 "licensePlate": t.license_plate,
                 "driverName": t.driver_name,
+                "carrierName": t.carrier_name,
+                "transportMedium": t.transport_medium,
+                "startPoint": t.start_point,
+                "endPoint": t.end_point,
                 "freightAmount": t.freight_amount,
                 "transportStatus": t.transport_status,
-                "carrierName": t.carrier_name,
             }
         )
 
@@ -847,12 +1015,19 @@ def update_settlement_draft(
     settlement_id: str,
     payload: FreightSettlementUpdate,
     user_id: str,
+    user=None,
 ) -> PlFreightSettlement:
-    settlement = get_settlement_row(db, tenant_no, settlement_id)
-    if settlement.settlement_status != "pending":
-        raise HTTPException(status_code=400, detail="仅草稿可编辑")
+    settlement = get_settlement_row(db, tenant_no, settlement_id, user=user)
+    status = settlement.settlement_status
+    if status not in ("pending", "approved"):
+        raise HTTPException(status_code=400, detail="当前状态不可编辑")
 
     update_data = payload.model_dump(exclude_unset=True, by_alias=False)
+    if status == "approved":
+        allowed = {"invoice_amount", "invoice_no", "invoice_date", "invoice_attachment_urls", "remark"}
+        update_data = {k: v for k, v in update_data.items() if k in allowed}
+        if not update_data:
+            raise HTTPException(status_code=400, detail="无有效更新字段")
     for key, value in update_data.items():
         setattr(settlement, key, value)
     settlement.update_by = user_id

+ 215 - 0
backend/scripts/apply_pl_data_scope.py

@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+"""为 PL API 批量接入数据权限(PR4 Phase 3)。"""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+IMPORT_LINE = "from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope\n"
+
+
+def ensure_import(content: str) -> str:
+    if "from app.services.pl_data_scope import" in content:
+        return content
+    marker = "from app.core.db_auth import DbUserContext, get_db_current_user\n"
+    if marker in content:
+        return content.replace(marker, marker + IMPORT_LINE)
+    return content
+
+
+def patch_get_helper(content: str, fn_name: str, model_name: str) -> str:
+    """_get_xxx(db, tenant_no, id) -> _get_xxx(db, tenant_no, id, user) + assert."""
+    old_sig = f"def {fn_name}(db: Session, tenant_no: str, "
+    if f"def {fn_name}(db: Session, tenant_no: str, " not in content:
+        return content
+    if f"def {fn_name}(db: Session, tenant_no: str, " in content and "user: DbUserContext" in content.split(f"def {fn_name}")[1].split("\n\n")[0]:
+        return content
+
+    pattern = rf"(def {fn_name}\(\n    db: Session,\n    tenant_no: str,\n    \w+_id: str,\n\) -> \w+:\n)(    \w+ = \([\s\S]*?\n    if not \w+:\n        raise HTTPException\(status_code=404[^\n]+\n    return \w+\n)"
+    repl = rf"\1\2".replace("\\2", "")  # noqa - use explicit replacement below
+
+    def replacer(m):
+        body = m.group(0)
+        if "assert_pl_row_in_scope" in body:
+            return body
+        var = re.search(r"if not (\w+):", body).group(1)
+        return (
+            body.replace(
+                f"def {fn_name}(db: Session, tenant_no: str, ",
+                f"def {fn_name}(db: Session, tenant_no: str, ",
+            )
+            .replace(
+                f") -> ",
+                f",\n    user: DbUserContext,\n) -> ",
+                1,
+            )
+            if ",\n    user: DbUserContext," not in body
+            else body
+        )
+
+    # simpler manual replace per function
+    old = f"""def {fn_name}(db: Session, tenant_no: str, """
+    # find entity id param name
+    m = re.search(rf"def {fn_name}\(db: Session, tenant_no: str, (\w+): str\)", content)
+    if not m:
+        return content
+    id_param = m.group(1)
+    old_block = f"""def {fn_name}(db: Session, tenant_no: str, {id_param}: str) ->"""
+    if f"user: DbUserContext" in content[content.find(old_block) : content.find(old_block) + 200]:
+        return content
+    new_block = f"""def {fn_name}(db: Session, tenant_no: str, {id_param}: str, user: DbUserContext) ->"""
+    content = content.replace(old_block, new_block, 1)
+    # add assert before return
+    needle = f"if not {id_param.split('_')[0] if False else ''}"
+    # find variable name from query
+    fn_body_start = content.find(new_block)
+    fn_chunk = content[fn_body_start : fn_body_start + 600]
+    var_m = re.search(r"(\w+) = \(\n        db\.query", fn_chunk)
+    if not var_m:
+        var_m = re.search(r"(\w+) = \(\n        db\.query", content[fn_body_start : fn_body_start + 800])
+    var = var_m.group(1) if var_m else id_param.replace("_id", "").replace("carrier", "carrier")
+    if var_m:
+        var = var_m.group(1)
+    else:
+        # guess from id_param
+        var = {"carrier_id": "carrier", "vehicle_id": "vehicle", "personnel_id": "personnel", "plan_id": "plan", "transport_id": "transport"}.get(id_param, "row")
+
+    insert = f"""    assert_pl_row_in_scope(db, {var}, {model_name}, user, tenant_no)
+    return {var}
+"""
+    old_return = f"    return {var}\n"
+    if f"assert_pl_row_in_scope(db, {var}," not in content:
+        content = content.replace(old_return, insert, 1)
+    # fix _get calls: _get_xxx(db, tenant_no, id) -> _get_xxx(db, tenant_no, id, user)
+    content = re.sub(
+        rf"{fn_name}\(db, tenant_no, ([^,\)]+)\)",
+        rf"{fn_name}(db, tenant_no, \1, user)",
+        content,
+    )
+    return content
+
+
+def add_scope_before_order(content: str, model: str, anchor: str) -> str:
+    """在 order_by 前插入 apply_pl_scope。"""
+    if f"apply_pl_scope(query, {model}," in content:
+        return content
+    idx = content.find(anchor)
+    if idx == -1:
+        return content
+    insert = f"    query = apply_pl_scope(query, {model}, user, db, tenant_no)\n\n    "
+    # insert before anchor's line
+    line_start = content.rfind("\n", 0, idx) + 1
+    return content[:line_start] + insert + content[line_start:]
+
+
+def patch_base_data():
+    path = ROOT / "app/api/pl/base_data.py"
+    c = ensure_import(path.read_text())
+    c = add_scope_before_order(c, "PlCarrier", "    query = query.order_by(\n        case((PlCarrier.order_num")
+    c = add_scope_before_order(c, "PlVehicle", "    vehicles = query.order_by(")
+    if "vehicles = query.order_by(" not in c:
+        c = add_scope_before_order(c, "PlVehicle", "    query = query.order_by(")
+    c = add_scope_before_order(c, "PlPersonnel", "    query = query.order_by(\n        case((PlPersonnel.order_num")
+    # driver list - second PlPersonnel order_by
+    if c.count("apply_pl_scope(query, PlPersonnel,") < 2:
+        c = c.replace(
+            "    query = query.order_by(\n        case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(),\n        PlPersonnel.create_time.desc(),\n    )\n    return [_personnel_to_driver_read(p) for p in query.all()]",
+            "    query = apply_pl_scope(query, PlPersonnel, user, db, tenant_no)\n\n    query = query.order_by(\n        case((PlPersonnel.order_num.is_(None), 1), else_=0).asc(),\n        PlPersonnel.create_time.desc(),\n    )\n    return [_personnel_to_driver_read(p) for p in query.all()]",
+            1,
+        )
+    for fn, model in (
+        ("_get_carrier", "PlCarrier"),
+        ("_get_vehicle", "PlVehicle"),
+        ("_get_personnel", "PlPersonnel"),
+    ):
+        c = patch_get_helper(c, fn, model)
+    path.write_text(c)
+    print("patched base_data.py")
+
+
+def patch_plan():
+    path = ROOT / "app/api/pl/plan.py"
+    c = ensure_import(path.read_text())
+    if "apply_pl_scope(query, PlPlan," not in c:
+        c = c.replace(
+            "    total = query.count()\n    offset = (page - 1) * page_size\n    plans = query.order_by(PlPlan.create_time.desc())",
+            "    query = apply_pl_scope(query, PlPlan, user, db, tenant_no)\n\n    total = query.count()\n    offset = (page - 1) * page_size\n    plans = query.order_by(PlPlan.create_time.desc())",
+            1,
+        )
+    c = patch_get_helper(c, "_get_plan", "PlPlan")
+    path.write_text(c)
+    print("patched plan.py")
+
+
+def patch_transport():
+    path = ROOT / "app/api/pl/transport.py"
+    c = ensure_import(path.read_text())
+    if "apply_pl_scope(query, PlTransport," not in c:
+        c = c.replace(
+            "    total = query.count()\n    offset = (page - 1) * page_size\n    transports = query.order_by(PlTransport.create_time.desc())",
+            "    query = apply_pl_scope(query, PlTransport, user, db, tenant_no)\n\n    total = query.count()\n    offset = (page - 1) * page_size\n    transports = query.order_by(PlTransport.create_time.desc())",
+            1,
+        )
+    c = patch_get_helper(c, "_get_transport", "PlTransport")
+    path.write_text(c)
+    print("patched transport.py")
+
+
+def patch_inventory():
+    path = ROOT / "app/api/pl/inventory.py"
+    c = ensure_import(path.read_text())
+    replacements = [
+        (
+            "    total = query.count()\n    offset = (page - 1) * page_size\n    items = query.order_by(PlInventory.product_name)",
+            "    query = apply_pl_scope(query, PlInventory, user, db, tenant_no)\n\n    total = query.count()\n    offset = (page - 1) * page_size\n    items = query.order_by(PlInventory.product_name)",
+        ),
+        (
+            "    return query.order_by(PlInventoryWarning.create_time.desc()).all()",
+            "    query = apply_pl_scope(query, PlInventoryWarning, user, db, tenant_no)\n    return query.order_by(PlInventoryWarning.create_time.desc()).all()",
+        ),
+        (
+            "    total = query.count()\n    offset = (page - 1) * page_size\n    items = query.order_by(PlInventoryTransaction.create_time.desc())",
+            "    query = apply_pl_scope(query, PlInventoryTransaction, user, db, tenant_no)\n\n    total = query.count()\n    offset = (page - 1) * page_size\n    items = query.order_by(PlInventoryTransaction.create_time.desc())",
+        ),
+        (
+            "    query = db.query(PlInventoryTransaction).filter(\n        PlInventoryTransaction.tenant_no == tenant_no,\n        PlInventoryTransaction.del_flag == 0,\n        PlInventoryTransaction.create_time >= date_from,\n        PlInventoryTransaction.create_time <= date_to,\n    )\n\n    if product_id:",
+            "    query = db.query(PlInventoryTransaction).filter(\n        PlInventoryTransaction.tenant_no == tenant_no,\n        PlInventoryTransaction.del_flag == 0,\n        PlInventoryTransaction.create_time >= date_from,\n        PlInventoryTransaction.create_time <= date_to,\n    )\n    query = apply_pl_scope(query, PlInventoryTransaction, user, db, tenant_no)\n\n    if product_id:",
+        ),
+        (
+            "    return query.order_by(PlDocumentReminder.create_time.desc()).all()",
+            "    query = apply_pl_scope(query, PlDocumentReminder, user, db, tenant_no)\n    return query.order_by(PlDocumentReminder.create_time.desc()).all()",
+        ),
+    ]
+    for old, new in replacements:
+        if old in c and new not in c:
+            c = c.replace(old, new, 1)
+    # warning update assert
+    if "assert_pl_row_in_scope(db, warning," not in c:
+        c = c.replace(
+            "    if not warning:\n        raise HTTPException(status_code=404, detail=\"预警不存在\")\n\n    if payload.status == \"resolved\":",
+            "    if not warning:\n        raise HTTPException(status_code=404, detail=\"预警不存在\")\n    assert_pl_row_in_scope(db, warning, PlInventoryWarning, user, tenant_no)\n\n    if payload.status == \"resolved\":",
+            1,
+        )
+    if "assert_pl_row_in_scope(db, reminder," not in c:
+        c = c.replace(
+            "    if not reminder:\n        raise HTTPException(status_code=404, detail=\"提醒不存在\")",
+            "    if not reminder:\n        raise HTTPException(status_code=404, detail=\"提醒不存在\")\n    assert_pl_row_in_scope(db, reminder, PlDocumentReminder, user, tenant_no)",
+            1,
+        )
+    if "assert_pl_row_in_scope(db, inventory," not in c:
+        c = c.replace(
+            "    if inventory:\n        inventory.low_stock_warning = payload.low_stock_warning",
+            "    if inventory:\n        assert_pl_row_in_scope(db, inventory, PlInventory, user, tenant_no)\n        inventory.low_stock_warning = payload.low_stock_warning",
+            1,
+        )
+    path.write_text(c)
+    print("patched inventory.py")
+
+
+if __name__ == "__main__":
+    patch_base_data()
+    patch_plan()
+    patch_transport()
+    patch_inventory()