Files
crm_project/server/app/api/finance.py
T
hankin 423baff73b v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成
- Docker bridge 网络隔离(8000 端口封死)
- Gunicorn 4 Worker 多进程
- Alembic 数据库迁移基线
- 日志轮转 20m×3
- JWT 密钥 + DB 密码 + CORS 收紧
- 3-2-1 备份链路(NAS + R740-B 冷备)
- 连接池 pool_pre_ping + pool_recycle=3600
2026-03-16 07:31:37 +00:00

151 lines
5.9 KiB
Python

"""
财务票据域路由 —— /api/finance
薄路由层:参数解析 + 调用 Service + 包装响应
"""
from __future__ import annotations
import uuid
import os
import time
import base64
from fastapi import APIRouter, Depends, Query, Body, File, UploadFile, Form
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db.database import get_db
from app.schemas.auth import CurrentUserPayload
from app.schemas.finance import ExpenseCreate, ExpenseStatusUpdate, InvoiceCreate
from app.schemas.response import ok
from app.core.exceptions import BizException
from app.services import finance_service as svc
router = APIRouter(prefix="/finance", tags=["财务票据"])
@router.post("/ocr", summary="上传票据图片并做 AI 发票/名片 OCR 识别")
async def ocr_recognize(
file: UploadFile = File(...),
scene: str = Form("invoice"),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
from app.services.ocr_service import ocr_image
# 读取并在本地保存原始文件
file_bytes = await file.read()
upload_dir = "uploads/finance"
os.makedirs(upload_dir, exist_ok=True)
ext = os.path.splitext(file.filename or "")[1].lower() or ".png"
ts = int(time.time())
safe_filename = f"{ts}_{current_user.user_id}{ext}"
file_path = os.path.join(upload_dir, safe_filename)
with open(file_path, "wb") as f:
f.write(file_bytes)
file_url = f"/uploads/finance/{safe_filename}"
# 仅支持图片(png/jpg/jpeg)和 PDF,不再支持 MD/TXT
supported = {".png", ".jpg", ".jpeg", ".pdf"}
if ext not in supported:
raise BizException(message=f"不支持的文件格式 {ext},仅支持: {', '.join(supported)}")
# 如果是 PDF,转成 PNG 再做 OCR
ocr_bytes = file_bytes
if ext == ".pdf":
try:
import fitz # PyMuPDF
doc = fitz.open(stream=file_bytes, filetype="pdf")
page = doc[0] # 取第一页
# 中等分辨率渲染(150 DPI,平衡质量与大小)
pix = page.get_pixmap(dpi=150)
ocr_bytes = pix.tobytes("png")
doc.close()
print(f"[OCR] PDF 转 PNG 成功: {len(ocr_bytes)} bytes")
except Exception as e:
print(f"[OCR] PDF 转换失败: {e}")
return ok(data={"ocr_data": {}, "file_url": file_url}, message=f"PDF 转换失败: {e}")
# 转换为纯 base64 传给 OCR
image_base64 = base64.b64encode(ocr_bytes).decode("utf-8")
result = await ocr_image(image_base64, scene)
if result.get("success"):
return ok(data={"ocr_data": result["data"], "file_url": file_url}, message="AI OCR 识别成功")
return ok(data={"ocr_data": result.get("data", {}), "file_url": file_url}, message=result.get("error", "OCR 识别失败"))
@router.post("/invoices", summary="上传票据入池(含 AI/OCR JSONB 数据)")
async def create_invoice(
body: InvoiceCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_invoice(db, current_user, body)
return ok(data=result.model_dump(mode="json"), message="票据入池成功")
@router.get("/invoices", summary="票据池列表(数据权限隔离)")
async def list_invoices(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
type: str | None = Query(None, alias="category", pattern=r"^(expense|customer)$"),
is_used: bool | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.list_invoices(db, current_user, page, size, type, is_used)
return ok(data=result.model_dump(mode="json"))
@router.delete("/invoices/{invoice_id}", summary="作废票据(软删除)")
async def void_invoice(
invoice_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
await svc.void_invoice(db, current_user, invoice_id)
return ok(message="票据已作废")
@router.post("/expenses", summary="生成报销单(防重锁定 + 强事务)")
async def create_expense(
body: ExpenseCreate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.create_expense(db, current_user, body)
return ok(data=result.model_dump(mode="json"), message=f"报销单 {result.system_no} 提交成功")
@router.get("/expenses", summary="报销单大盘(多角色数据权限)")
async def list_expenses(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: str | None = Query(None, pattern=r"^(submitted|approved|rejected|voided)$"),
applicant_id: uuid.UUID | None = Query(None, description="按申请人过滤(管理员用)"),
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.list_expenses(db, current_user, page, size, status, applicant_id)
return ok(data=result.model_dump(mode="json"))
@router.get("/expenses/{expense_id}", summary="报销单详情(含明细行)")
async def get_expense(
expense_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
result = await svc.get_expense(db, current_user, expense_id)
return ok(data=result.model_dump(mode="json"))
@router.put("/expenses/{expense_id}/status", summary="审批/撤回报销单(含发票释放)")
async def update_expense_status(
expense_id: uuid.UUID,
body: ExpenseStatusUpdate,
db: AsyncSession = Depends(get_db),
current_user: CurrentUserPayload = Depends(get_current_user),
) -> dict:
msg = await svc.update_expense_status(db, current_user, expense_id, body)
return ok(message=msg)