Преглед изворни кода

feat(bx): 报销模块接入数据权限过滤

新增 bx_data_scope 与 BxUserContext.to_user_info,列表/我的单据 AND scope,待办审批不加 scope,审批操作 skip_scope。

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu пре 4 месеци
родитељ
комит
2905dc3d5f

+ 12 - 12
backend/app/api/bx/advance.py

@@ -67,7 +67,7 @@ def list_advances(
         d0, d1 = bounds
         filters.append(BxAdvanceApply.create_time >= d0)
         filters.append(BxAdvanceApply.create_time < d1)
-    rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters)
+    rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
     out_rows = [AdvanceApplyOut.model_validate(r) for r in rows]
     _fill_offset_amounts(db, tenant_no, rows, out_rows)
     return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
@@ -104,7 +104,7 @@ def my_advances(
         d0, d1 = bounds
         filters.append(BxAdvanceApply.create_time >= d0)
         filters.append(BxAdvanceApply.create_time < d1)
-    rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters)
+    rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
     out_rows = [AdvanceApplyOut.model_validate(r) for r in rows]
     _fill_offset_amounts(db, tenant_no, rows, out_rows)
     return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
@@ -116,7 +116,7 @@ def my_advance_stats(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    data = AdvanceService.my_apply_stats(db, user.id, tenant_no)
+    data = AdvanceService.my_apply_stats(db, user, tenant_no)
     return ApiResponse(data=AdvanceMyStatsOut.model_validate(data))
 
 
@@ -126,7 +126,7 @@ def advance_status_counts(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    return ApiResponse(data=AdvanceService.global_status_counts(db, tenant_no))
+    return ApiResponse(data=AdvanceService.global_status_counts(db, tenant_no, user=user))
 
 
 @router.get("/pending-approval", response_model=ApiResponse[PagedData[AdvanceApplyOut]])
@@ -198,7 +198,7 @@ def get_advance(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    advance = AdvanceService.get_by_id(db, advance_id, tenant_no)
+    advance = AdvanceService.get_by_id(db, advance_id, tenant_no, user=user)
     if not advance:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
     out = AdvanceApplyOut.model_validate(advance)
@@ -214,9 +214,9 @@ def get_approvals(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    if not AdvanceService.get_by_id(db, advance_id, tenant_no):
+    if not AdvanceService.get_by_id(db, advance_id, tenant_no, user=user):
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
-    approvals = AdvanceService.get_approvals(db, advance_id, tenant_no)
+    approvals = AdvanceService.get_approvals(db, advance_id, tenant_no, user=user)
     return ApiResponse(data=[AdvanceApprovalOut.model_validate(a) for a in approvals])
 
 
@@ -227,9 +227,9 @@ def get_approval_history(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    if not AdvanceService.get_by_id(db, advance_id, tenant_no):
+    if not AdvanceService.get_by_id(db, advance_id, tenant_no, user=user):
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
-    history = AdvanceService.get_approval_history(db, advance_id, tenant_no)
+    history = AdvanceService.get_approval_history(db, advance_id, tenant_no, user=user)
     return ApiResponse(data=[AdvanceApprovalOut.model_validate(a) for a in history])
 
 
@@ -243,7 +243,7 @@ def create_advance(
     data = body.model_dump(exclude_unset=True)
     eid = data.get("expense_item_id")
     if eid:
-        if not ExpenseItemService.get_by_id(db, eid, tenant_no):
+        if not ExpenseItemService.get_by_id(db, eid, tenant_no, user=user):
             raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"费用项目 {eid} 不存在")
     data["applicant_id"] = user.id
     data["applicant_name"] = user.name
@@ -277,7 +277,7 @@ def update_advance(
     data = body.model_dump(exclude_unset=True)
     if data.get("expense_item_id"):
         eid = data["expense_item_id"]
-        if not ExpenseItemService.get_by_id(db, eid, tenant_no):
+        if not ExpenseItemService.get_by_id(db, eid, tenant_no, user=user):
             raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"费用项目 {eid} 不存在")
     advance = AdvanceService.update(db, advance_id, data, user=user)
     if not advance:
@@ -338,7 +338,7 @@ def delete_advance(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    if not AdvanceService.get_by_id(db, advance_id, tenant_no):
+    if not AdvanceService.get_by_id(db, advance_id, tenant_no, user=user):
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="备用金申请不存在")
     AdvanceService.soft_delete(db, advance_id, user=user)
     db.commit()

+ 6 - 4
backend/app/api/bx/attachment.py

@@ -70,7 +70,7 @@ def download_file(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    attachment = AttachmentService.get_by_id(db, attachment_id, tenant_no)
+    attachment = AttachmentService.get_by_id(db, attachment_id, tenant_no, user=user)
     if not attachment:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
     try:
@@ -92,7 +92,7 @@ def delete_attachment(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    if not AttachmentService.get_by_id(db, attachment_id, tenant_no):
+    if not AttachmentService.get_by_id(db, attachment_id, tenant_no, user=user):
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
     success = AttachmentService.soft_delete(db, attachment_id, user=user)
     db.commit()
@@ -107,7 +107,7 @@ def preview_attachment(
 ):
     """返回图片/附件预览 URL(MinIO 预签名或本地公开地址,对齐门户 storage 能力)。"""
     tenant_no = _tenant(db, user)
-    attachment = AttachmentService.get_by_id(db, attachment_id, tenant_no)
+    attachment = AttachmentService.get_by_id(db, attachment_id, tenant_no, user=user)
     if not attachment:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
     try:
@@ -132,7 +132,9 @@ def list_by_biz(
 ):
     tenant_no = _tenant(db, user)
     biz_id_value = str(biz_id).strip() if biz_id else "0"
-    attachments = AttachmentService.get_by_biz(db, biz_type=biz_type, biz_id=biz_id_value, tenant_no=tenant_no)
+    attachments = AttachmentService.get_by_biz(
+        db, biz_type=biz_type, biz_id=biz_id_value, tenant_no=tenant_no, user=user
+    )
     return ApiResponse(data=[AttachmentOut.model_validate(a) for a in attachments])
 
 

+ 3 - 3
backend/app/api/bx/expense_item.py

@@ -19,7 +19,7 @@ def get_expense_item_tree(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    tree = ExpenseItemService.get_item_tree(db, tenant_no)
+    tree = ExpenseItemService.get_item_tree(db, tenant_no, user=user)
     return ApiResponse(data=[ExpenseItemOut.model_validate(i) for i in tree])
 
 
@@ -30,7 +30,7 @@ def get_expense_item(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    item = ExpenseItemService.get_by_id(db, item_id, tenant_no)
+    item = ExpenseItemService.get_by_id(db, item_id, tenant_no, user=user)
     if not item:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="费用项目不存在")
     return ApiResponse(data=ExpenseItemOut.model_validate(item))
@@ -80,7 +80,7 @@ def delete_expense_item(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    item = ExpenseItemService.get_by_id(db, item_id, tenant_no)
+    item = ExpenseItemService.get_by_id(db, item_id, tenant_no, user=user)
     if not item:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="费用项目不存在")
     ExpenseItemService.soft_delete(db, item_id, user=user)

+ 9 - 7
backend/app/api/bx/payment.py

@@ -70,7 +70,7 @@ def list_pending_payments(
         d0, d1 = bounds
         filters.append(BxReimburseApply.create_time >= d0)
         filters.append(BxReimburseApply.create_time < d1)
-    rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters)
+    rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
     outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
     return ApiResponse(
         data=PagedData(rows=_wrap_reimburse_rows(outs), total=total, page=page, page_size=pageSize)
@@ -83,7 +83,7 @@ def payment_finance_stats(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    data = ReimburseService.finance_payment_stats(db, tenant_no)
+    data = ReimburseService.finance_payment_stats(db, tenant_no, user=user)
     return ApiResponse(data=PaymentFinanceStatsOut.model_validate(data))
 
 
@@ -113,7 +113,7 @@ def list_processed_payments(
         d0, d1 = bounds
         filters.append(BxReimburseApply.create_time >= d0)
         filters.append(BxReimburseApply.create_time < d1)
-    rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters)
+    rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
     outs = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
     return ApiResponse(
         data=PagedData(rows=_wrap_reimburse_rows(outs), total=total, page=page, page_size=pageSize)
@@ -220,7 +220,7 @@ def list_advance_issues(
         d0, d1 = bounds
         filters.append(BxAdvanceApply.create_time >= d0)
         filters.append(BxAdvanceApply.create_time < d1)
-    rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters)
+    rows, total = AdvanceService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
     out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
     if out_rows:
         offset_map = AdvanceService.offset_amount_map(db, tenant_no, [r.id for r in rows])
@@ -235,7 +235,7 @@ def advance_issue_stats(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    data = AdvanceService.finance_issue_stats(db, tenant_no)
+    data = AdvanceService.finance_issue_stats(db, tenant_no, user=user)
     return ApiResponse(data=AdvanceFinanceIssueStatsOut.model_validate(data))
 
 
@@ -271,7 +271,7 @@ def export_advance_issues(
         d0, d1 = bounds
         filters.append(BxAdvanceApply.create_time >= d0)
         filters.append(BxAdvanceApply.create_time < d1)
-    rows, _ = AdvanceService.get_paged(db, tenant_no, page=1, page_size=99999, filters=filters)
+    rows, _ = AdvanceService.get_paged(db, tenant_no, page=1, page_size=99999, filters=filters, user=user)
     out_rows = [AdvanceIssueOut.model_validate(r) for r in rows]
 
     buf = io.StringIO()
@@ -323,7 +323,7 @@ def issue_advance(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    issue_atts = AttachmentService.get_by_biz(db, "advance_issue", advance_id, tenant_no)
+    issue_atts = AttachmentService.get_by_biz(db, "advance_issue", advance_id, tenant_no, user=user)
     if not issue_atts:
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少上传 1 个发放附件")
     advance = AdvanceService.issue(
@@ -346,6 +346,7 @@ def list_advance_offset_records(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
+    AdvanceService.get_by_id(db, advance_id, tenant_no, user=user)
     rows = (
         db.query(BxReimburseOffset, BxReimburseApply)
         .join(
@@ -385,6 +386,7 @@ def list_reimburse_offset_records(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
+    ReimburseService.get_by_id(db, payment_id, tenant_no, user=user)
     rows = (
         db.query(BxReimburseOffset, BxAdvanceApply)
         .join(BxAdvanceApply, BxAdvanceApply.id == BxReimburseOffset.advance_id)

+ 11 - 11
backend/app/api/bx/reimburse.py

@@ -33,7 +33,7 @@ def _tenant(db: Session, user: BxUserContext) -> str:
 def _build_details_data(db: Session, tenant_no: str, body_details) -> List[dict]:
     details_data: List[dict] = []
     for d in body_details:
-        item = ExpenseItemService.get_by_id(db, d.expense_item_id, tenant_no)
+        item = ExpenseItemService.get_by_id(db, d.expense_item_id, tenant_no, user=user)
         if not item:
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
@@ -95,7 +95,7 @@ def list_reimburses(
         d0, d1 = bounds
         filters.append(BxReimburseApply.create_time >= d0)
         filters.append(BxReimburseApply.create_time < d1)
-    rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters)
+    rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
     out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
     return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
 
@@ -131,7 +131,7 @@ def my_reimburses(
         d0, d1 = bounds
         filters.append(BxReimburseApply.create_time >= d0)
         filters.append(BxReimburseApply.create_time < d1)
-    rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters)
+    rows, total = ReimburseService.get_paged(db, tenant_no, page=page, page_size=pageSize, filters=filters, user=user)
     out_rows = ReimburseService.out_rows_with_attachment_counts(db, tenant_no, list(rows))
     return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize))
 
@@ -142,7 +142,7 @@ def my_reimburse_stats(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    data = ReimburseService.my_apply_stats(db, user.id, tenant_no)
+    data = ReimburseService.my_apply_stats(db, user, tenant_no)
     return ApiResponse(data=ReimburseMyStatsOut.model_validate(data))
 
 
@@ -152,7 +152,7 @@ def reimburse_status_counts(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    return ApiResponse(data=ReimburseService.global_status_counts(db, tenant_no))
+    return ApiResponse(data=ReimburseService.global_status_counts(db, tenant_no, user=user))
 
 
 @router.get("/pending-approval", response_model=ApiResponse[PagedData[ReimburseApplyOut]])
@@ -224,7 +224,7 @@ def get_reimburse(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    reimburse = ReimburseService.get_by_id(db, reimburse_id, tenant_no)
+    reimburse = ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user)
     if not reimburse:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
     out = ReimburseService.build_apply_out_with_details(db, tenant_no, reimburse)
@@ -238,9 +238,9 @@ def get_approvals(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    if not ReimburseService.get_by_id(db, reimburse_id, tenant_no):
+    if not ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user):
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
-    approvals = ReimburseService.get_approvals(db, reimburse_id, tenant_no)
+    approvals = ReimburseService.get_approvals(db, reimburse_id, tenant_no, user=user)
     return ApiResponse(data=[ReimburseApprovalOut.model_validate(a) for a in approvals])
 
 
@@ -251,9 +251,9 @@ def get_approval_history(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    if not ReimburseService.get_by_id(db, reimburse_id, tenant_no):
+    if not ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user):
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
-    history = ReimburseService.get_approval_history(db, reimburse_id, tenant_no)
+    history = ReimburseService.get_approval_history(db, reimburse_id, tenant_no, user=user)
     return ApiResponse(data=[ReimburseApprovalOut.model_validate(a) for a in history])
 
 
@@ -374,7 +374,7 @@ def delete_reimburse(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = _tenant(db, user)
-    if not ReimburseService.get_by_id(db, reimburse_id, tenant_no):
+    if not ReimburseService.get_by_id(db, reimburse_id, tenant_no, user=user):
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报销单不存在")
     ReimburseService.soft_delete(db, reimburse_id, user=user)
     db.commit()

+ 4 - 4
backend/app/api/bx/workflow.py

@@ -26,7 +26,7 @@ def list_workflows(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    rows, total = WorkflowService.get_paged(db, tenant_no, page=page, page_size=pageSize)
+    rows, total = WorkflowService.get_paged(db, tenant_no, page=page, page_size=pageSize, user=user)
     out_rows = [WorkflowDefOut.model_validate(r) for r in rows]
     paged_data = PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)
     return ApiResponse(data=paged_data)
@@ -51,7 +51,7 @@ def get_workflow(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    workflow = WorkflowService.get_by_id(db, workflow_id, tenant_no)
+    workflow = WorkflowService.get_by_id(db, workflow_id, tenant_no, user=user)
     if not workflow:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="工作流不存在")
     return ApiResponse(data=WorkflowDefOut.model_validate(workflow))
@@ -107,7 +107,7 @@ def save_workflow_nodes(
     except ValueError as exc:
         db.rollback()
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
-    workflow = WorkflowService.get_by_id(db, workflow_id, tenant_no)
+    workflow = WorkflowService.get_by_id(db, workflow_id, tenant_no, user=user)
     if not workflow:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="工作流不存在")
     db.commit()
@@ -122,7 +122,7 @@ def delete_workflow(
     user: BxUserContext = Depends(get_bx_current_user),
 ):
     tenant_no = get_tenant_no(db, user.to_tenant_user_dict())
-    workflow = WorkflowService.get_by_id(db, workflow_id, tenant_no)
+    workflow = WorkflowService.get_by_id(db, workflow_id, tenant_no, user=user)
     if not workflow:
         raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="工作流不存在")
     WorkflowService.soft_delete(db, workflow_id, user=user)

+ 10 - 0
backend/app/core/bx_auth.py

@@ -27,6 +27,16 @@ class BxUserContext:
             "tenant_no": self.tenant_no,
         }
 
+    def to_user_info(self) -> Dict[str, Any]:
+        """与门户 JWT / apply_data_scope 所需的 user_info 字段对齐。"""
+        return {
+            "user_id": self.id,
+            "role": self.role,
+            "tenant_no": self.tenant_no,
+            "dept_id": self.dept_id,
+            "region_id": self.dept_id,
+        }
+
 
 def get_bx_current_user(
     user: dict = Depends(require_admin_or_super_admin),

+ 58 - 28
backend/app/services/bx/advance.py

@@ -18,6 +18,7 @@ from app.models.bx_models import (
 )
 from app.services.bx.approval_cycle import filter_current_cycle_approvals
 from app.services.bx.base import BxCrudService
+from app.services.bx_data_scope import apply_bx_scope
 from app.utils.bx_codegen import generate_apply_code
 from app.utils.bx_date_range import current_month_bounds, parse_create_time_day_bounds
 
@@ -28,8 +29,17 @@ class AdvanceService:
         return get_tenant_no(db, user.to_tenant_user_dict())
 
     @staticmethod
-    def get_by_id(db: Session, advance_id: str, tenant_no: str) -> Optional[BxAdvanceApply]:
-        return BxCrudService.get_by_id(BxAdvanceApply, db, advance_id, tenant_no)
+    def get_by_id(
+        db: Session,
+        advance_id: str,
+        tenant_no: str,
+        *,
+        user: Optional[BxUserContext] = None,
+        skip_scope: bool = False,
+    ) -> Optional[BxAdvanceApply]:
+        return BxCrudService.get_by_id(
+            BxAdvanceApply, db, advance_id, tenant_no, user=user, skip_scope=skip_scope
+        )
 
     @staticmethod
     def get_paged(
@@ -38,8 +48,12 @@ class AdvanceService:
         page: int,
         page_size: int,
         filters: Optional[List[Any]] = None,
+        *,
+        user: Optional[BxUserContext] = None,
     ) -> Tuple[Sequence[BxAdvanceApply], int]:
-        return BxCrudService.get_paged(BxAdvanceApply, db, tenant_no, page, page_size, filters)
+        return BxCrudService.get_paged(
+            BxAdvanceApply, db, tenant_no, page, page_size, filters, user=user
+        )
 
     @staticmethod
     def create(
@@ -74,13 +88,14 @@ class AdvanceService:
             tenant_no,
             obj_in,
             user_id=user.id,
+            user=user,
         )
 
     @staticmethod
     def soft_delete(db: Session, advance_id: str, *, user: BxUserContext) -> bool:
         tenant_no = AdvanceService._tenant_no(db, user)
         return BxCrudService.soft_delete(
-            BxAdvanceApply, db, advance_id, tenant_no, user_id=user.id
+            BxAdvanceApply, db, advance_id, tenant_no, user_id=user.id, user=user
         )
 
     @staticmethod
@@ -178,6 +193,7 @@ class AdvanceService:
         if bounds:
             d0, d1 = bounds
             query = query.filter(BxAdvanceApply.create_time >= d0, BxAdvanceApply.create_time < d1)
+        query = apply_bx_scope(query, BxAdvanceApply, user, db, tenant_no)
         total = query.count()
         rows = (
             query.order_by(BxAdvanceApply.create_time.desc())
@@ -203,7 +219,7 @@ class AdvanceService:
     @staticmethod
     def submit(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxAdvanceApply]:
         tenant_no = AdvanceService._tenant_no(db, user)
-        apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, user=user)
         if not apply_obj:
             return None
         apply_obj.status = "pending"
@@ -222,7 +238,7 @@ class AdvanceService:
         db: Session, apply_id: str, approver: BxUserContext, opinion: str
     ) -> Optional[BxAdvanceApply]:
         tenant_no = AdvanceService._tenant_no(db, approver)
-        apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
         if not apply_obj or not apply_obj.wf_id:
             return None
         nodes = AdvanceService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
@@ -267,7 +283,7 @@ class AdvanceService:
         db: Session, apply_id: str, approver: BxUserContext, opinion: str
     ) -> Optional[BxAdvanceApply]:
         tenant_no = AdvanceService._tenant_no(db, approver)
-        apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
         if not apply_obj or not apply_obj.wf_id:
             return None
         nodes = AdvanceService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
@@ -342,8 +358,10 @@ class AdvanceService:
         )
 
     @staticmethod
-    def get_approvals(db: Session, apply_id: str, tenant_no: str) -> List[Dict[str, Any]]:
-        apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no)
+    def get_approvals(
+        db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
+    ) -> List[Dict[str, Any]]:
+        apply_obj = AdvanceService.get_by_id(db, apply_id, tenant_no, user=user)
         if not apply_obj or not apply_obj.wf_id:
             return []
         wf = BxCrudService.get_by_id(BxWorkflowDef, db, apply_obj.wf_id, tenant_no)
@@ -376,9 +394,11 @@ class AdvanceService:
         return result
 
     @staticmethod
-    def get_approval_history(db: Session, apply_id: str, tenant_no: str) -> List[Dict[str, Any]]:
+    def get_approval_history(
+        db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
+    ) -> List[Dict[str, Any]]:
         """返回全部审批动作,按时间正序排列,用于展示完整审批历史。"""
-        if not AdvanceService.get_by_id(db, apply_id, tenant_no):
+        if not AdvanceService.get_by_id(db, apply_id, tenant_no, user=user):
             return []
         approvals = (
             db.query(BxAdvanceApproval)
@@ -393,26 +413,33 @@ class AdvanceService:
         return [AdvanceService._approval_record_dict(a, apply_id) for a in approvals]
 
     @staticmethod
-    def global_status_counts(db: Session, tenant_no: str) -> Dict[str, int]:
-        rows = (
-            db.query(BxAdvanceApply.status, func.count())
-            .filter(BxAdvanceApply.del_flag == 0, BxAdvanceApply.tenant_no == tenant_no)
-            .group_by(BxAdvanceApply.status)
-            .all()
+    def global_status_counts(db: Session, tenant_no: str, *, user: BxUserContext) -> Dict[str, int]:
+        query = db.query(BxAdvanceApply.status, func.count()).filter(
+            BxAdvanceApply.del_flag == 0, BxAdvanceApply.tenant_no == tenant_no
         )
+        query = apply_bx_scope(query, BxAdvanceApply, user, db, tenant_no)
+        rows = query.group_by(BxAdvanceApply.status).all()
         return {str(s or ""): int(c) for s, c in rows}
 
     @staticmethod
-    def my_apply_stats(db: Session, user_id: str, tenant_no: str) -> dict:
-        base = and_(
+    def _my_apply_base(db: Session, user: BxUserContext, tenant_no: str):
+        query = db.query(BxAdvanceApply).filter(
             BxAdvanceApply.del_flag == 0,
             BxAdvanceApply.tenant_no == tenant_no,
             or_(
-                BxAdvanceApply.applicant_id == user_id,
-                BxAdvanceApply.create_by == user_id,
+                BxAdvanceApply.applicant_id == user.id,
+                BxAdvanceApply.create_by == user.id,
             ),
         )
-        total_count = db.query(func.count()).select_from(BxAdvanceApply).filter(base).scalar() or 0
+        return apply_bx_scope(query, BxAdvanceApply, user, db, tenant_no)
+
+    @staticmethod
+    def my_apply_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
+        base_q = AdvanceService._my_apply_base(db, user, tenant_no).subquery()
+        base = BxAdvanceApply.id.in_(db.query(base_q.c.id))
+        total_count = (
+            db.query(func.count()).select_from(BxAdvanceApply).filter(base).scalar() or 0
+        )
         pending = (
             db.query(func.count())
             .select_from(BxAdvanceApply)
@@ -466,9 +493,7 @@ class AdvanceService:
             db.query(func.coalesce(func.sum(BxReimburseOffset.offset_amount), 0))
             .join(BxAdvanceApply, BxReimburseOffset.advance_id == BxAdvanceApply.id)
             .filter(
-                BxAdvanceApply.applicant_id == user_id,
-                BxAdvanceApply.del_flag == 0,
-                BxAdvanceApply.tenant_no == tenant_no,
+                BxAdvanceApply.id.in_(db.query(base_q.c.id)),
                 BxReimburseOffset.tenant_no == tenant_no,
                 BxReimburseOffset.del_flag == 0,
             )
@@ -547,7 +572,7 @@ class AdvanceService:
         actual_amount: Optional[float] = None,
     ) -> Optional[BxAdvanceApply]:
         tenant_no = AdvanceService._tenant_no(db, user)
-        apply_obj = AdvanceService.get_by_id(db, advance_id, tenant_no)
+        apply_obj = AdvanceService.get_by_id(db, advance_id, tenant_no, user=user)
         if not apply_obj:
             return None
         apply_obj.status = "issued"
@@ -564,8 +589,13 @@ class AdvanceService:
         return apply_obj
 
     @staticmethod
-    def finance_issue_stats(db: Session, tenant_no: str) -> dict:
-        base = and_(BxAdvanceApply.del_flag == 0, BxAdvanceApply.tenant_no == tenant_no)
+    def finance_issue_stats(db: Session, tenant_no: str, *, user: BxUserContext) -> dict:
+        base_q = (
+            db.query(BxAdvanceApply)
+            .filter(BxAdvanceApply.del_flag == 0, BxAdvanceApply.tenant_no == tenant_no)
+        )
+        base_q = apply_bx_scope(base_q, BxAdvanceApply, user, db, tenant_no).subquery()
+        base = BxAdvanceApply.id.in_(db.query(base_q.c.id))
         pending_f = and_(
             BxAdvanceApply.status == "approved",
             or_(BxAdvanceApply.issue_status.is_(None), BxAdvanceApply.issue_status != "issued"),

+ 21 - 4
backend/app/services/bx/attachment.py

@@ -1,6 +1,6 @@
 from __future__ import annotations
 
-from typing import List, Optional
+from typing import List, Optional, Optional
 
 from sqlalchemy import case as sql_case, func, or_
 from sqlalchemy.orm import Session
@@ -9,6 +9,7 @@ from app.core.bx_auth import BxUserContext
 from app.core.tenant_context import get_tenant_no
 from app.models.bx_models import BxAttachment
 from app.services.bx.base import BxCrudService
+from app.services.bx_data_scope import assert_bx_attachment_biz_in_scope, assert_bx_biz_in_scope
 
 
 class AttachmentService:
@@ -17,8 +18,17 @@ class AttachmentService:
         return get_tenant_no(db, user.to_tenant_user_dict())
 
     @staticmethod
-    def get_by_id(db: Session, attachment_id: str, tenant_no: str) -> Optional[BxAttachment]:
-        return BxCrudService.get_by_id(BxAttachment, db, attachment_id, tenant_no)
+    def get_by_id(
+        db: Session,
+        attachment_id: str,
+        tenant_no: str,
+        *,
+        user: Optional[BxUserContext] = None,
+    ) -> Optional[BxAttachment]:
+        row = BxCrudService.get_by_id(BxAttachment, db, attachment_id, tenant_no)
+        if row and user:
+            assert_bx_attachment_biz_in_scope(db, row, user, tenant_no)
+        return row
 
     @staticmethod
     def create(
@@ -40,12 +50,18 @@ class AttachmentService:
     @staticmethod
     def soft_delete(db: Session, attachment_id: str, *, user: BxUserContext) -> bool:
         tenant_no = AttachmentService._tenant_no(db, user)
+        row = AttachmentService.get_by_id(db, attachment_id, tenant_no, user=user)
+        if not row:
+            return False
         return BxCrudService.soft_delete(
             BxAttachment, db, attachment_id, tenant_no, user_id=user.id
         )
 
     @staticmethod
-    def get_by_biz(db: Session, biz_type: str, biz_id: str, tenant_no: str) -> List[BxAttachment]:
+    def get_by_biz(
+        db: Session, biz_type: str, biz_id: str, tenant_no: str, *, user: BxUserContext
+    ) -> List[BxAttachment]:
+        assert_bx_biz_in_scope(db, biz_type, biz_id, user, tenant_no)
         query = db.query(BxAttachment).filter(
             BxAttachment.biz_type == biz_type,
             BxAttachment.biz_id == biz_id,
@@ -64,6 +80,7 @@ class AttachmentService:
         user: BxUserContext,
     ) -> int:
         tenant_no = AttachmentService._tenant_no(db, user)
+        assert_bx_biz_in_scope(db, biz_type, biz_id, user, tenant_no)
         if not ids:
             return 0
         count = (

+ 40 - 6
backend/app/services/bx/base.py

@@ -1,22 +1,37 @@
 """报销模块 Service 基类 — 租户隔离 + UUID + 软删除。"""
 from __future__ import annotations
 
-from typing import Any, List, Optional, Sequence, Tuple, Type, TypeVar
+from typing import TYPE_CHECKING, Any, List, Optional, Sequence, Tuple, Type, TypeVar
 
 from sqlalchemy.orm import Session
 
 from app.core.utils import generate_uuid
+from app.services.bx_data_scope import apply_bx_scope, assert_bx_row_in_scope
+
+if TYPE_CHECKING:
+    from app.core.bx_auth import BxUserContext
 
 M = TypeVar("M")
 
 
 class BxCrudService:
     @staticmethod
-    def get_by_id(model: Type[M], db: Session, id: str, tenant_no: str) -> Optional[M]:
+    def get_by_id(
+        model: Type[M],
+        db: Session,
+        id: str,
+        tenant_no: str,
+        *,
+        user: Optional["BxUserContext"] = None,
+        skip_scope: bool = False,
+    ) -> Optional[M]:
         query = db.query(model).filter(model.id == id, model.tenant_no == tenant_no)
         if hasattr(model, "del_flag"):
             query = query.filter(model.del_flag == 0)
-        return query.first()
+        row = query.first()
+        if row and user and not skip_scope:
+            assert_bx_row_in_scope(db, row, model, user, tenant_no)
+        return row
 
     @staticmethod
     def get_paged(
@@ -26,12 +41,16 @@ class BxCrudService:
         page: int,
         page_size: int,
         filters: Optional[List[Any]] = None,
+        *,
+        user: Optional["BxUserContext"] = None,
     ) -> Tuple[Sequence[M], int]:
         query = db.query(model).filter(model.tenant_no == tenant_no)
         if hasattr(model, "del_flag"):
             query = query.filter(model.del_flag == 0)
         if filters:
             query = query.filter(*filters)
+        if user:
+            query = apply_bx_scope(query, model, user, db, tenant_no)
         total = query.count()
         if hasattr(model, "sort_order"):
             query = query.order_by(model.sort_order.asc(), model.create_time.desc())
@@ -69,8 +88,12 @@ class BxCrudService:
         obj_in: dict,
         *,
         user_id: str,
+        user: Optional["BxUserContext"] = None,
+        skip_scope: bool = False,
     ) -> Optional[M]:
-        obj = BxCrudService.get_by_id(model, db, id, tenant_no)
+        obj = BxCrudService.get_by_id(
+            model, db, id, tenant_no, user=user, skip_scope=skip_scope
+        )
         if not obj:
             return None
         for key, value in obj_in.items():
@@ -81,8 +104,19 @@ class BxCrudService:
         return obj
 
     @staticmethod
-    def soft_delete(model: Type[M], db: Session, id: str, tenant_no: str, *, user_id: str = "") -> bool:
-        obj = BxCrudService.get_by_id(model, db, id, tenant_no)
+    def soft_delete(
+        model: Type[M],
+        db: Session,
+        id: str,
+        tenant_no: str,
+        *,
+        user_id: str = "",
+        user: Optional["BxUserContext"] = None,
+        skip_scope: bool = False,
+    ) -> bool:
+        obj = BxCrudService.get_by_id(
+            model, db, id, tenant_no, user=user, skip_scope=skip_scope
+        )
         if not obj:
             return False
         obj.del_flag = 1

+ 23 - 11
backend/app/services/bx/expense_item.py

@@ -8,6 +8,7 @@ from app.core.bx_auth import BxUserContext
 from app.core.tenant_context import get_tenant_no
 from app.models.bx_models import BxExpenseItem
 from app.services.bx.base import BxCrudService
+from app.services.bx_data_scope import apply_bx_scope
 
 
 class ExpenseItemService:
@@ -16,8 +17,17 @@ class ExpenseItemService:
         return get_tenant_no(db, user.to_tenant_user_dict())
 
     @staticmethod
-    def get_by_id(db: Session, item_id: str, tenant_no: str) -> Optional[BxExpenseItem]:
-        return BxCrudService.get_by_id(BxExpenseItem, db, item_id, tenant_no)
+    def get_by_id(
+        db: Session,
+        item_id: str,
+        tenant_no: str,
+        *,
+        user: Optional[BxUserContext] = None,
+        skip_scope: bool = False,
+    ) -> Optional[BxExpenseItem]:
+        return BxCrudService.get_by_id(
+            BxExpenseItem, db, item_id, tenant_no, user=user, skip_scope=skip_scope
+        )
 
     @staticmethod
     def create(
@@ -31,7 +41,7 @@ class ExpenseItemService:
         parent_id = data.get("parent_id") or "0"
         data["parent_id"] = parent_id
         if parent_id and parent_id != "0":
-            parent = ExpenseItemService.get_by_id(db, parent_id, tenant_no)
+            parent = ExpenseItemService.get_by_id(db, parent_id, tenant_no, user=user)
             if not parent:
                 raise ValueError("父级费用项目不存在")
             data.setdefault("level", (parent.level or 1) + 1)
@@ -63,7 +73,7 @@ class ExpenseItemService:
             if parent_id != "0":
                 if parent_id == item_id:
                     raise ValueError("不能将项目设置为自己的父级")
-                parent = ExpenseItemService.get_by_id(db, parent_id, tenant_no)
+                parent = ExpenseItemService.get_by_id(db, parent_id, tenant_no, user=user)
                 if not parent:
                     raise ValueError("父级费用项目不存在")
         return BxCrudService.update(
@@ -73,23 +83,25 @@ class ExpenseItemService:
             tenant_no,
             data,
             user_id=user.id,
+            user=user,
         )
 
     @staticmethod
     def soft_delete(db: Session, item_id: str, *, user: BxUserContext) -> bool:
         tenant_no = ExpenseItemService._tenant_no(db, user)
         return BxCrudService.soft_delete(
-            BxExpenseItem, db, item_id, tenant_no, user_id=user.id
+            BxExpenseItem, db, item_id, tenant_no, user_id=user.id, user=user
         )
 
     @staticmethod
-    def get_item_tree(db: Session, tenant_no: str) -> List[BxExpenseItem]:
-        items = (
-            db.query(BxExpenseItem)
-            .filter(BxExpenseItem.del_flag == 0, BxExpenseItem.tenant_no == tenant_no)
-            .order_by(BxExpenseItem.sort_order.asc(), BxExpenseItem.create_time.desc())
-            .all()
+    def get_item_tree(db: Session, tenant_no: str, *, user: BxUserContext) -> List[BxExpenseItem]:
+        query = db.query(BxExpenseItem).filter(
+            BxExpenseItem.del_flag == 0, BxExpenseItem.tenant_no == tenant_no
         )
+        query = apply_bx_scope(query, BxExpenseItem, user, db, tenant_no)
+        items = query.order_by(
+            BxExpenseItem.sort_order.asc(), BxExpenseItem.create_time.desc()
+        ).all()
         return ExpenseItemService._build_tree(items)
 
     @staticmethod

+ 59 - 28
backend/app/services/bx/reimburse.py

@@ -23,6 +23,7 @@ from app.schemas.bx.reimburse import ReimburseApplyOut, ReimburseDetailOut
 from app.services.bx.approval_cycle import filter_current_cycle_approvals
 from app.services.bx.attachment import AttachmentService
 from app.services.bx.base import BxCrudService
+from app.services.bx_data_scope import apply_bx_scope
 from app.utils.bx_codegen import (
     allocate_reimburse_title_seq,
     generate_apply_code,
@@ -42,8 +43,17 @@ class ReimburseService:
         return get_tenant_no(db, user.to_tenant_user_dict())
 
     @staticmethod
-    def get_by_id(db: Session, reimburse_id: str, tenant_no: str) -> Optional[BxReimburseApply]:
-        return BxCrudService.get_by_id(BxReimburseApply, db, reimburse_id, tenant_no)
+    def get_by_id(
+        db: Session,
+        reimburse_id: str,
+        tenant_no: str,
+        *,
+        user: Optional[BxUserContext] = None,
+        skip_scope: bool = False,
+    ) -> Optional[BxReimburseApply]:
+        return BxCrudService.get_by_id(
+            BxReimburseApply, db, reimburse_id, tenant_no, user=user, skip_scope=skip_scope
+        )
 
     @staticmethod
     def get_paged(
@@ -52,14 +62,18 @@ class ReimburseService:
         page: int,
         page_size: int,
         filters: Optional[List[Any]] = None,
+        *,
+        user: Optional[BxUserContext] = None,
     ) -> Tuple[Sequence[BxReimburseApply], int]:
-        return BxCrudService.get_paged(BxReimburseApply, db, tenant_no, page, page_size, filters)
+        return BxCrudService.get_paged(
+            BxReimburseApply, db, tenant_no, page, page_size, filters, user=user
+        )
 
     @staticmethod
     def soft_delete(db: Session, reimburse_id: str, *, user: BxUserContext) -> bool:
         tenant_no = ReimburseService._tenant_no(db, user)
         return BxCrudService.soft_delete(
-            BxReimburseApply, db, reimburse_id, tenant_no, user_id=user.id
+            BxReimburseApply, db, reimburse_id, tenant_no, user_id=user.id, user=user
         )
 
     @staticmethod
@@ -170,6 +184,7 @@ class ReimburseService:
         if bounds:
             d0, d1 = bounds
             query = query.filter(BxReimburseApply.create_time >= d0, BxReimburseApply.create_time < d1)
+        query = apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
         total = query.count()
         rows = (
             query.order_by(BxReimburseApply.create_time.desc())
@@ -182,7 +197,7 @@ class ReimburseService:
     @staticmethod
     def submit(db: Session, apply_id: str, user: BxUserContext) -> Optional[BxReimburseApply]:
         tenant_no = ReimburseService._tenant_no(db, user)
-        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
         if not apply_obj:
             return None
         apply_obj.status = "pending"
@@ -201,7 +216,7 @@ class ReimburseService:
         db: Session, apply_id: str, approver: BxUserContext, opinion: str
     ) -> Optional[BxReimburseApply]:
         tenant_no = ReimburseService._tenant_no(db, approver)
-        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
         if not apply_obj or not apply_obj.wf_id:
             return None
         nodes = ReimburseService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
@@ -246,7 +261,7 @@ class ReimburseService:
         db: Session, apply_id: str, approver: BxUserContext, opinion: str
     ) -> Optional[BxReimburseApply]:
         tenant_no = ReimburseService._tenant_no(db, approver)
-        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, skip_scope=True)
         if not apply_obj or not apply_obj.wf_id:
             return None
         nodes = ReimburseService._workflow_nodes(db, apply_obj.wf_id, tenant_no)
@@ -321,8 +336,10 @@ class ReimburseService:
         )
 
     @staticmethod
-    def get_approvals(db: Session, apply_id: str, tenant_no: str) -> List[Dict[str, Any]]:
-        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no)
+    def get_approvals(
+        db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
+    ) -> List[Dict[str, Any]]:
+        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
         if not apply_obj or not apply_obj.wf_id:
             return []
         wf = BxCrudService.get_by_id(BxWorkflowDef, db, apply_obj.wf_id, tenant_no)
@@ -355,9 +372,11 @@ class ReimburseService:
         return result
 
     @staticmethod
-    def get_approval_history(db: Session, apply_id: str, tenant_no: str) -> List[Dict[str, Any]]:
+    def get_approval_history(
+        db: Session, apply_id: str, tenant_no: str, *, user: Optional[BxUserContext] = None
+    ) -> List[Dict[str, Any]]:
         """返回全部审批动作,按时间正序排列,用于展示完整审批历史。"""
-        if not ReimburseService.get_by_id(db, apply_id, tenant_no):
+        if not ReimburseService.get_by_id(db, apply_id, tenant_no, user=user):
             return []
         approvals = (
             db.query(BxReimburseApproval)
@@ -445,7 +464,7 @@ class ReimburseService:
         user: BxUserContext,
     ) -> Optional[BxReimburseApply]:
         tenant_no = ReimburseService._tenant_no(db, user)
-        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
         if not apply_obj:
             return None
 
@@ -560,26 +579,33 @@ class ReimburseService:
         return out
 
     @staticmethod
-    def global_status_counts(db: Session, tenant_no: str) -> Dict[str, int]:
-        rows = (
-            db.query(BxReimburseApply.status, func.count())
-            .filter(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
-            .group_by(BxReimburseApply.status)
-            .all()
+    def global_status_counts(db: Session, tenant_no: str, *, user: BxUserContext) -> Dict[str, int]:
+        query = db.query(BxReimburseApply.status, func.count()).filter(
+            BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no
         )
+        query = apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
+        rows = query.group_by(BxReimburseApply.status).all()
         return {str(s or ""): int(c) for s, c in rows}
 
     @staticmethod
-    def my_apply_stats(db: Session, user_id: str, tenant_no: str) -> dict:
-        base = and_(
+    def _my_apply_base(db: Session, user: BxUserContext, tenant_no: str):
+        query = db.query(BxReimburseApply).filter(
             BxReimburseApply.del_flag == 0,
             BxReimburseApply.tenant_no == tenant_no,
             or_(
-                BxReimburseApply.applicant_id == user_id,
-                BxReimburseApply.create_by == user_id,
+                BxReimburseApply.applicant_id == user.id,
+                BxReimburseApply.create_by == user.id,
             ),
         )
-        total_count = db.query(func.count()).select_from(BxReimburseApply).filter(base).scalar() or 0
+        return apply_bx_scope(query, BxReimburseApply, user, db, tenant_no)
+
+    @staticmethod
+    def my_apply_stats(db: Session, user: BxUserContext, tenant_no: str) -> dict:
+        base_q = ReimburseService._my_apply_base(db, user, tenant_no).subquery()
+        base = BxReimburseApply.id.in_(db.query(base_q.c.id))
+        total_count = (
+            db.query(func.count()).select_from(BxReimburseApply).filter(base).scalar() or 0
+        )
         pending = (
             db.query(func.count())
             .select_from(BxReimburseApply)
@@ -671,7 +697,7 @@ class ReimburseService:
         actual_amount: Optional[float] = None,
     ) -> Optional[BxReimburseApply]:
         tenant_no = ReimburseService._tenant_no(db, user)
-        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
         if not apply_obj:
             return None
         apply_obj.status = "processed"
@@ -695,7 +721,7 @@ class ReimburseService:
         remark: str = "",
     ) -> Optional[BxReimburseApply]:
         tenant_no = ReimburseService._tenant_no(db, user)
-        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
         if not apply_obj:
             return None
         apply_obj.status = "offset"
@@ -726,7 +752,7 @@ class ReimburseService:
         remark: str = "",
     ) -> Optional[BxReimburseApply]:
         tenant_no = ReimburseService._tenant_no(db, user)
-        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no)
+        apply_obj = ReimburseService.get_by_id(db, apply_id, tenant_no, user=user)
         if not apply_obj:
             return None
 
@@ -772,8 +798,13 @@ class ReimburseService:
         return apply_obj
 
     @staticmethod
-    def finance_payment_stats(db: Session, tenant_no: str) -> dict:
-        base = and_(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
+    def finance_payment_stats(db: Session, tenant_no: str, *, user: BxUserContext) -> dict:
+        base_q = (
+            db.query(BxReimburseApply)
+            .filter(BxReimburseApply.del_flag == 0, BxReimburseApply.tenant_no == tenant_no)
+        )
+        base_q = apply_bx_scope(base_q, BxReimburseApply, user, db, tenant_no).subquery()
+        base = BxReimburseApply.id.in_(db.query(base_q.c.id))
         pending = (
             db.query(func.count())
             .select_from(BxReimburseApply)

+ 19 - 5
backend/app/services/bx/workflow.py

@@ -29,8 +29,17 @@ class WorkflowService:
         return get_tenant_no(db, user.to_tenant_user_dict())
 
     @staticmethod
-    def get_by_id(db: Session, workflow_id: str, tenant_no: str) -> Optional[BxWorkflowDef]:
-        return BxCrudService.get_by_id(BxWorkflowDef, db, workflow_id, tenant_no)
+    def get_by_id(
+        db: Session,
+        workflow_id: str,
+        tenant_no: str,
+        *,
+        user: Optional[BxUserContext] = None,
+        skip_scope: bool = False,
+    ) -> Optional[BxWorkflowDef]:
+        return BxCrudService.get_by_id(
+            BxWorkflowDef, db, workflow_id, tenant_no, user=user, skip_scope=skip_scope
+        )
 
     @staticmethod
     def get_paged(
@@ -38,8 +47,12 @@ class WorkflowService:
         tenant_no: str,
         page: int,
         page_size: int,
+        *,
+        user: Optional[BxUserContext] = None,
     ) -> Tuple[Sequence[BxWorkflowDef], int]:
-        return BxCrudService.get_paged(BxWorkflowDef, db, tenant_no, page, page_size)
+        return BxCrudService.get_paged(
+            BxWorkflowDef, db, tenant_no, page, page_size, user=user
+        )
 
     @staticmethod
     def create(
@@ -74,13 +87,14 @@ class WorkflowService:
             tenant_no,
             obj_in,
             user_id=user.id,
+            user=user,
         )
 
     @staticmethod
     def soft_delete(db: Session, workflow_id: str, *, user: BxUserContext) -> bool:
         tenant_no = WorkflowService._tenant_no(db, user)
         deleted = BxCrudService.soft_delete(
-            BxWorkflowDef, db, workflow_id, tenant_no, user_id=user.id
+            BxWorkflowDef, db, workflow_id, tenant_no, user_id=user.id, user=user
         )
         if deleted:
             db.query(BxWorkflowNode).filter(
@@ -142,7 +156,7 @@ class WorkflowService:
         user: BxUserContext,
     ) -> List[BxWorkflowNode]:
         tenant_no = WorkflowService._tenant_no(db, user)
-        workflow = WorkflowService.get_by_id(db, wf_id, tenant_no)
+        workflow = WorkflowService.get_by_id(db, wf_id, tenant_no, user=user)
         if not workflow:
             raise ValueError("工作流不存在")
 

+ 76 - 0
backend/app/services/bx_data_scope.py

@@ -0,0 +1,76 @@
+"""BX 模块数据权限辅助(create_by / create_dept_id)。"""
+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.bx_auth import BxUserContext
+
+
+def apply_bx_scope(query, model, user: "BxUserContext", db: Session, tenant_no: str):
+    """列表/统计查询应用数据权限。"""
+    return apply_data_scope(query, model, user.to_user_info(), db, tenant_no)
+
+
+def assert_bx_row_in_scope(
+    db: Session,
+    row,
+    model,
+    user: "BxUserContext",
+    tenant_no: str,
+) -> None:
+    """详情/改/删前校验行级数据权限。"""
+    assert_row_in_data_scope(db, row, model, user.to_user_info(), tenant_no)
+
+
+def assert_bx_biz_in_scope(
+    db: Session,
+    biz_type: str,
+    biz_id: str,
+    user: "BxUserContext",
+    tenant_no: str,
+) -> None:
+    """附件下载/删除/绑定前校验关联业务单在数据权限内。"""
+    bid = (biz_id or "").strip()
+    if not bid or bid == "0":
+        return
+    btype = (biz_type or "").strip().lower()
+    if btype in ("advance", "advance_apply", "advance_issue"):
+        from app.services.bx.advance import AdvanceService
+
+        row = AdvanceService.get_by_id(db, bid, tenant_no, user=user)
+        if not row:
+            raise HTTPException(status_code=404, detail="关联备用金申请不存在")
+        return
+    if btype in ("reimburse", "reimburse_apply"):
+        from app.services.bx.reimburse import ReimburseService
+
+        row = ReimburseService.get_by_id(db, bid, tenant_no, user=user)
+        if not row:
+            raise HTTPException(status_code=404, detail="关联报销单不存在")
+        return
+    if btype == "workflow":
+        from app.services.bx.workflow import WorkflowService
+
+        row = WorkflowService.get_by_id(db, bid, tenant_no, user=user)
+        if not row:
+            raise HTTPException(status_code=404, detail="关联工作流不存在")
+
+
+def assert_bx_attachment_biz_in_scope(
+    db: Session,
+    attachment,
+    user: "BxUserContext",
+    tenant_no: str,
+) -> None:
+    """按附件关联业务单校验数据权限。"""
+    assert_bx_biz_in_scope(
+        db,
+        getattr(attachment, "biz_type", None) or "",
+        getattr(attachment, "biz_id", None) or "",
+        user,
+        tenant_no,
+    )