From 7a26e5cf5899dfefc67c09883cbe2eab21bb6cdc Mon Sep 17 00:00:00 2001 From: zlwu Date: Sat, 25 Jul 2026 19:13:23 +0800 Subject: [PATCH] feat(bx): invoice upload/delete APIs and wallet summary Co-authored-by: Cursor --- backend/app/api/bx/__init__.py | 3 +- backend/app/api/bx/invoice.py | 129 ++++++++++ backend/app/schemas/bx/invoice.py | 20 +- backend/app/services/bx/invoice.py | 367 +++++++++++++++++++++++++++++ 4 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 backend/app/api/bx/invoice.py create mode 100644 backend/app/services/bx/invoice.py diff --git a/backend/app/api/bx/__init__.py b/backend/app/api/bx/__init__.py index 4139197f8..090bfc6d1 100644 --- a/backend/app/api/bx/__init__.py +++ b/backend/app/api/bx/__init__.py @@ -1,7 +1,7 @@ """报销(bx)API 路由聚合 — REST 风格,协议对齐 bx 原系统。""" from fastapi import APIRouter -from app.api.bx import advance, attachment, expense_item, payment, reimburse, workflow +from app.api.bx import advance, attachment, expense_item, invoice, payment, reimburse, workflow from app.schemas.bx.common import ApiResponse bx_router = APIRouter(tags=["报销管理"]) @@ -11,6 +11,7 @@ bx_router.include_router(advance.router) bx_router.include_router(attachment.router) bx_router.include_router(reimburse.router) bx_router.include_router(payment.router) +bx_router.include_router(invoice.router) @bx_router.get("/bx/health", response_model=ApiResponse[dict]) diff --git a/backend/app/api/bx/invoice.py b/backend/app/api/bx/invoice.py new file mode 100644 index 000000000..e9092fb77 --- /dev/null +++ b/backend/app/api/bx/invoice.py @@ -0,0 +1,129 @@ +import mimetypes +from typing import List, Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status +from sqlalchemy.orm import Session + +from app.core.bx_auth import BxUserContext, get_bx_current_user +from app.core.database import get_db +from app.schemas.bx.common import ApiResponse, PagedData +from app.schemas.bx.invoice import InvoiceOut, LedgerOut, WalletSummaryOut +from app.services.bx.invoice import InvoiceService + +router = APIRouter(tags=["发票夹"]) + + +@router.get("/invoice-wallets/mine/summary", response_model=ApiResponse[WalletSummaryOut]) +def get_mine_wallet_summary( + db: Session = Depends(get_db), + user: BxUserContext = Depends(get_bx_current_user), +): + data = InvoiceService.get_mine_summary(db, user=user) + return ApiResponse(data=WalletSummaryOut.model_validate(data)) + + +@router.get("/invoice-wallets", response_model=ApiResponse[PagedData[WalletSummaryOut]]) +def list_invoice_wallets( + page: int = 1, + pageSize: int = 10, + keyword: str = "", + db: Session = Depends(get_db), + user: BxUserContext = Depends(get_bx_current_user), +): + rows, total = InvoiceService.list_wallets( + db, user=user, page=page, page_size=pageSize, keyword=keyword + ) + out_rows = [WalletSummaryOut.model_validate(InvoiceService._summary_to_dict(r)) for r in rows] + return ApiResponse(data=PagedData(rows=out_rows, total=total, page=page, page_size=pageSize)) + + +@router.get("/invoices", response_model=ApiResponse[PagedData[InvoiceOut]]) +def list_invoices( + page: int = 1, + pageSize: int = 10, + userId: Optional[str] = None, + db: Session = Depends(get_db), + user: BxUserContext = Depends(get_bx_current_user), +): + tenant_no = InvoiceService._tenant_no(db, user) + rows, total = InvoiceService.list_invoices( + db, user=user, owner_user_id=userId, page=page, page_size=pageSize + ) + out_rows = InvoiceService.out_rows_with_attachments(db, tenant_no, list(rows)) + return ApiResponse( + data=PagedData( + rows=[InvoiceOut.model_validate(r) for r in out_rows], + total=total, + page=page, + page_size=pageSize, + ) + ) + + +@router.post("/invoices", response_model=ApiResponse[InvoiceOut]) +def create_invoice( + file: UploadFile = File(...), + amount: float = Form(...), + remark: str = Form(""), + targetUserId: Optional[str] = Form(None), + db: Session = Depends(get_db), + user: BxUserContext = Depends(get_bx_current_user), +): + if not file or not file.filename: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择文件") + file_data = file.file.read() + content_type = ( + file.content_type + or mimetypes.guess_type(file.filename or "")[0] + or "application/octet-stream" + ) + try: + invoice = InvoiceService.create( + db, + user=user, + amount=amount, + file_data=file_data, + filename=file.filename or "unknown", + content_type=content_type, + remark=remark, + target_user_id=targetUserId, + ) + db.commit() + db.refresh(invoice) + except HTTPException: + db.rollback() + raise + except Exception as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="上传发票失败" + ) from exc + + tenant_no = InvoiceService._tenant_no(db, user) + out = InvoiceService.out_rows_with_attachments(db, tenant_no, [invoice])[0] + return ApiResponse(data=InvoiceOut.model_validate(out)) + + +@router.delete("/invoices/{invoice_id}", response_model=ApiResponse[bool]) +def delete_invoice( + invoice_id: str, + db: Session = Depends(get_db), + user: BxUserContext = Depends(get_bx_current_user), +): + try: + success = InvoiceService.delete(db, user=user, invoice_id=invoice_id) + db.commit() + except HTTPException: + db.rollback() + raise + return ApiResponse(data=success) + + +@router.get("/invoice-ledgers", response_model=ApiResponse[List[LedgerOut]]) +def list_invoice_ledgers( + userId: Optional[str] = None, + db: Session = Depends(get_db), + user: BxUserContext = Depends(get_bx_current_user), +): + rows = InvoiceService.list_ledgers(db, user=user, owner_user_id=userId) + return ApiResponse(data=[LedgerOut.model_validate(r) for r in rows]) diff --git a/backend/app/schemas/bx/invoice.py b/backend/app/schemas/bx/invoice.py index c3cd8945c..4a7ec503c 100644 --- a/backend/app/schemas/bx/invoice.py +++ b/backend/app/schemas/bx/invoice.py @@ -17,7 +17,7 @@ class WalletSummaryOut(BaseModel): balance: float invoice_count: int version: int - create_time: DatetimeToStr + create_time: Optional[DatetimeToStr] = None update_time: OptDatetimeToStr = None @@ -33,3 +33,21 @@ class LedgerOut(BaseModel): ref_ledger_id: Optional[str] = None remark: Optional[str] = None create_time: DatetimeToStr + + +class InvoiceOut(BaseModel): + model_config = ConfigDict(from_attributes=True, populate_by_name=True, alias_generator=to_camel) + + id: str + owner_user_id: str + owner_user_name: str + amount: float + upload_by: Optional[str] = None + upload_by_name: Optional[str] = None + remark: Optional[str] = None + create_time: DatetimeToStr + update_time: OptDatetimeToStr = None + attachment_id: Optional[str] = None + file_name: Optional[str] = None + file_path: Optional[str] = None + file_type: Optional[str] = None diff --git a/backend/app/services/bx/invoice.py b/backend/app/services/bx/invoice.py new file mode 100644 index 000000000..ab0126b1f --- /dev/null +++ b/backend/app/services/bx/invoice.py @@ -0,0 +1,367 @@ +from __future__ import annotations + +import mimetypes +from decimal import Decimal +from typing import List, Optional, Sequence, Tuple + +from fastapi import HTTPException, status +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.bx_auth import BxUserContext +from app.core.menu_guard import SUPER_ADMIN_ROLE +from app.core.tenant_context import get_tenant_no +from app.models.bx_models import BxAttachment, BxInvoice, BxInvoiceLedger, BxInvoiceWalletSummary +from app.models.models import SysUser +from app.services.bx.attachment import AttachmentService +from app.services.bx.base import BxCrudService +from app.services.bx.invoice_ledger import ( + ENTRY_CREDIT_UPLOAD, + ENTRY_DEBIT_DELETE, + InvoiceLedgerService, +) +from app.services.bx.storage import upload_bx_file +from app.services.web_auth_roles import iter_user_menu_perm_codes + +INVOICE_WALLET_ADMIN_PERM = "bx:invoiceWallet:admin" + + +class InvoiceService: + @staticmethod + def _tenant_no(db: Session, user: BxUserContext) -> str: + return get_tenant_no(db, user.to_tenant_user_dict()) + + @staticmethod + def is_wallet_admin(db: Session, user: BxUserContext) -> bool: + if user.role == SUPER_ADMIN_ROLE: + return True + codes = iter_user_menu_perm_codes(db, user.id, user.tenant_no) + return INVOICE_WALLET_ADMIN_PERM in codes + + @staticmethod + def _require_wallet_admin(db: Session, user: BxUserContext) -> None: + if not InvoiceService.is_wallet_admin(db, user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要发票夹管理权限") + + @staticmethod + def _lookup_user_name(db: Session, user_id: str) -> str: + row = ( + db.query(SysUser) + .filter(SysUser.id == user_id, SysUser.del_flag == 0) + .first() + ) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="目标用户不存在") + return (row.nick_name or row.user_no or "").strip() + + @staticmethod + def _resolve_owner( + db: Session, + user: BxUserContext, + target_user_id: Optional[str], + ) -> Tuple[str, str]: + target = (target_user_id or "").strip() + if not target or target == user.id: + return user.id, user.name + InvoiceService._require_wallet_admin(db, user) + return target, InvoiceService._lookup_user_name(db, target) + + @staticmethod + def _resolve_owner_for_query( + db: Session, + user: BxUserContext, + user_id: Optional[str], + ) -> str: + owner_id = (user_id or "").strip() or user.id + if owner_id != user.id: + InvoiceService._require_wallet_admin(db, user) + return owner_id + + @staticmethod + def _count_invoices(db: Session, tenant_no: str, owner_user_id: str) -> int: + return ( + db.query(BxInvoice) + .filter( + BxInvoice.tenant_no == tenant_no, + BxInvoice.owner_user_id == owner_user_id, + BxInvoice.del_flag == 0, + ) + .count() + ) + + @staticmethod + def get_mine_summary(db: Session, *, user: BxUserContext) -> dict: + tenant_no = InvoiceService._tenant_no(db, user) + row = ( + db.query(BxInvoiceWalletSummary) + .filter( + BxInvoiceWalletSummary.tenant_no == tenant_no, + BxInvoiceWalletSummary.owner_user_id == user.id, + BxInvoiceWalletSummary.del_flag == 0, + ) + .first() + ) + if row: + return InvoiceService._summary_to_dict(row) + totals = InvoiceLedgerService.calc_summary(db, tenant_no, user.id) + return { + "id": "", + "owner_user_id": user.id, + "owner_user_name": user.name, + "total_amount": totals["total_amount"], + "used_amount": totals["used_amount"], + "balance": totals["balance"], + "invoice_count": InvoiceService._count_invoices(db, tenant_no, user.id), + "version": 0, + "create_time": None, + "update_time": None, + } + + @staticmethod + def list_wallets( + db: Session, + *, + user: BxUserContext, + page: int = 1, + page_size: int = 10, + keyword: str = "", + ) -> Tuple[Sequence[BxInvoiceWalletSummary], int]: + InvoiceService._require_wallet_admin(db, user) + tenant_no = InvoiceService._tenant_no(db, user) + query = db.query(BxInvoiceWalletSummary).filter( + BxInvoiceWalletSummary.tenant_no == tenant_no, + BxInvoiceWalletSummary.del_flag == 0, + ) + kw = (keyword or "").strip() + if kw: + query = query.filter( + or_( + BxInvoiceWalletSummary.owner_user_name.contains(kw), + BxInvoiceWalletSummary.owner_user_id.contains(kw), + ) + ) + total = query.count() + rows = ( + query.order_by(BxInvoiceWalletSummary.update_time.desc(), BxInvoiceWalletSummary.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return rows, total + + @staticmethod + def list_invoices( + db: Session, + *, + user: BxUserContext, + owner_user_id: Optional[str] = None, + page: int = 1, + page_size: int = 10, + ) -> Tuple[Sequence[BxInvoice], int]: + tenant_no = InvoiceService._tenant_no(db, user) + owner_id = InvoiceService._resolve_owner_for_query(db, user, owner_user_id) + filters = [BxInvoice.owner_user_id == owner_id] + rows, total = BxCrudService.get_paged( + BxInvoice, + db, + tenant_no, + page, + page_size, + filters=filters, + user=user, + ) + return rows, total + + @staticmethod + def list_ledgers( + db: Session, + *, + user: BxUserContext, + owner_user_id: Optional[str] = None, + ) -> List[BxInvoiceLedger]: + tenant_no = InvoiceService._tenant_no(db, user) + owner_id = InvoiceService._resolve_owner_for_query(db, user, owner_user_id) + rows = InvoiceLedgerService.list_entries(db, tenant_no, owner_id) + return list(reversed(rows)) + + @staticmethod + def create( + db: Session, + *, + user: BxUserContext, + amount: Decimal, + file_data: bytes, + filename: str, + content_type: str, + remark: Optional[str] = None, + target_user_id: Optional[str] = None, + ) -> BxInvoice: + tenant_no = InvoiceService._tenant_no(db, user) + owner_id, owner_name = InvoiceService._resolve_owner(db, user, target_user_id) + amt = Decimal(str(amount)) + if amt <= 0: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="金额必须大于0") + if not file_data: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请选择文件") + + invoice = BxCrudService.create( + BxInvoice, + db, + { + "owner_user_id": owner_id, + "owner_user_name": owner_name, + "amount": amt, + "upload_by": user.id, + "upload_by_name": user.name, + "remark": (remark or "").strip() or None, + }, + tenant_no=tenant_no, + user_id=user.id, + dept_id=user.dept_id, + ) + + safe_name = filename or "unknown" + mime = content_type or mimetypes.guess_type(safe_name)[0] or "application/octet-stream" + relative_path, _full_url = upload_bx_file(file_data, safe_name, biz_type="invoice") + AttachmentService.create( + db, + { + "biz_type": "invoice", + "biz_id": invoice.id, + "file_name": safe_name, + "file_path": relative_path, + "file_type": mime, + "file_size": len(file_data), + "attach_type": "invoice", + }, + user=user, + ) + + InvoiceLedgerService.add_entry( + db, + tenant_no=tenant_no, + owner_user_id=owner_id, + entry_type=ENTRY_CREDIT_UPLOAD, + amount=amt, + user=user, + invoice_id=invoice.id, + ) + invoice_count = InvoiceService._count_invoices(db, tenant_no, owner_id) + InvoiceLedgerService.refresh_summary( + db, + tenant_no, + owner_id, + owner_name, + invoice_count, + user.id, + user.dept_id, + ) + return invoice + + @staticmethod + def delete(db: Session, *, user: BxUserContext, invoice_id: str) -> bool: + InvoiceService._require_wallet_admin(db, user) + tenant_no = InvoiceService._tenant_no(db, user) + invoice = BxCrudService.get_by_id( + BxInvoice, db, invoice_id, tenant_no, user=user, skip_scope=True + ) + if not invoice: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="发票不存在") + + InvoiceLedgerService.assert_can_delete_invoice( + db, tenant_no, invoice.owner_user_id, invoice.amount + ) + InvoiceLedgerService.add_entry( + db, + tenant_no=tenant_no, + owner_user_id=invoice.owner_user_id, + entry_type=ENTRY_DEBIT_DELETE, + amount=Decimal(str(invoice.amount)), + user=user, + invoice_id=invoice.id, + ) + + invoice.del_flag = 1 + invoice.update_by = user.id + db.query(BxAttachment).filter( + BxAttachment.biz_type == "invoice", + BxAttachment.biz_id == invoice.id, + BxAttachment.tenant_no == tenant_no, + BxAttachment.del_flag == 0, + ).update( + {BxAttachment.del_flag: 1, BxAttachment.update_by: user.id}, + synchronize_session="fetch", + ) + db.flush() + + invoice_count = InvoiceService._count_invoices(db, tenant_no, invoice.owner_user_id) + InvoiceLedgerService.refresh_summary( + db, + tenant_no, + invoice.owner_user_id, + invoice.owner_user_name, + invoice_count, + user.id, + user.dept_id, + ) + return True + + @staticmethod + def _summary_to_dict(row: BxInvoiceWalletSummary) -> dict: + return { + "id": row.id, + "owner_user_id": row.owner_user_id, + "owner_user_name": row.owner_user_name, + "total_amount": row.total_amount, + "used_amount": row.used_amount, + "balance": row.balance, + "invoice_count": row.invoice_count, + "version": row.version, + "create_time": row.create_time, + "update_time": row.update_time, + } + + @staticmethod + def out_rows_with_attachments( + db: Session, tenant_no: str, invoices: Sequence[BxInvoice] + ) -> List[dict]: + if not invoices: + return [] + ids = [inv.id for inv in invoices] + attachments = ( + db.query(BxAttachment) + .filter( + BxAttachment.biz_type == "invoice", + BxAttachment.biz_id.in_(ids), + BxAttachment.tenant_no == tenant_no, + BxAttachment.del_flag == 0, + ) + .order_by(BxAttachment.create_time.asc()) + .all() + ) + att_map: dict[str, BxAttachment] = {} + for att in attachments: + if att.biz_id not in att_map: + att_map[att.biz_id] = att + + out: List[dict] = [] + for inv in invoices: + att = att_map.get(inv.id) + out.append( + { + "id": inv.id, + "owner_user_id": inv.owner_user_id, + "owner_user_name": inv.owner_user_name, + "amount": inv.amount, + "upload_by": inv.upload_by, + "upload_by_name": inv.upload_by_name, + "remark": inv.remark, + "create_time": inv.create_time, + "update_time": inv.update_time, + "attachment_id": att.id if att else None, + "file_name": att.file_name if att else None, + "file_path": att.file_path if att else None, + "file_type": att.file_type if att else None, + } + ) + return out