feat: 新增票据夹模块并优化 OCR 与员工画像服务

后端新增票据夹端点、数据模型和服务模块,优化 OCR 端点
Schema 和附件操作逻辑,完善员工行为画像服务和辅助函数,
前端新增票据夹视图和服务层,优化文档中心样式和侧边栏导
航,完善员工画像详情弹窗和权限控制,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-29 14:51:18 +08:00
parent 678f64d772
commit 4c59941ec6
33 changed files with 2855 additions and 551 deletions

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.models.employee import Employee
from app.schemas.employee_profile import EmployeeProfileLatestRead
from app.services.account_behavior_profile import AccountBehaviorProfileService
from app.services.employee_behavior_profile_service import EmployeeBehaviorProfileService
router = APIRouter(prefix="/employee-profiles")
@@ -31,13 +32,13 @@ def get_current_employee_latest_profile(
) -> EmployeeProfileLatestRead:
employee = _resolve_current_employee(db, current_user)
if employee is None:
return EmployeeProfileLatestRead(
employee_id=current_user.username,
employee_name=current_user.name,
return AccountBehaviorProfileService(db).get_latest_account_profile(
account_id=current_user.username,
account_name=current_user.name,
identifiers=_current_account_identifiers(current_user),
scene=scene,
window_days=window_days,
expense_type_scope=expense_type_scope,
empty_reason="当前登录用户未匹配到员工目录,暂无法形成用户画像。",
)
service = EmployeeBehaviorProfileService(db)
@@ -47,7 +48,7 @@ def get_current_employee_latest_profile(
window_days=window_days,
expense_type_scope=expense_type_scope,
)
if latest.empty_reason:
if latest.empty_reason or _missing_usage_duration_metric(latest):
service.refresh_employee_profiles(
employee_id=employee.id,
window_days=(window_days,),
@@ -115,3 +116,24 @@ def _resolve_current_employee(
stmt = select(Employee).where(or_(*conditions)).order_by(Employee.created_at.asc()).limit(1)
return db.scalars(stmt).first()
def _missing_usage_duration_metric(latest: EmployeeProfileLatestRead) -> bool:
if latest.scene != "operations":
return False
for profile in latest.profiles:
if profile.profile_type == "ai_usage":
return "ai_run_duration_ms" not in profile.metrics
return False
def _current_account_identifiers(current_user: CurrentUserContext) -> set[str]:
return {
item
for item in (
current_user.username,
current_user.name,
)
if str(item or "").strip()
}

View File

@@ -2,13 +2,14 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.common import ErrorResponse
from app.schemas.ocr import OcrRecognizeBatchRead
from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService
router = APIRouter(prefix="/ocr")
@@ -35,8 +36,9 @@ router = APIRouter(prefix="/ocr")
)
async def recognize_ocr_documents(
files: Annotated[list[UploadFile], File(description="待识别的票据图片或 PDF。")],
_: Annotated[CurrentUserContext, Depends(get_current_user)],
current_user: Annotated[CurrentUserContext, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
receipt_ids: Annotated[list[str] | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None,
) -> OcrRecognizeBatchRead:
try:
payload = []
@@ -48,7 +50,13 @@ async def recognize_ocr_documents(
upload.content_type,
)
)
return OcrService(db).recognize_files(payload)
result = OcrService(db).recognize_files(payload)
return ReceiptFolderService().persist_ocr_batch(
files=payload,
result=result,
current_user=current_user,
receipt_ids=receipt_ids or [],
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
except RuntimeError as exc:

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
from app.api.deps import CurrentUserContext, get_current_user
from app.schemas.common import ErrorResponse
from app.schemas.receipt_folder import (
ReceiptFolderDeleteResponse,
ReceiptFolderDetailRead,
ReceiptFolderItemRead,
ReceiptFolderUpdate,
)
from app.services.receipt_folder import ReceiptFolderService
router = APIRouter(prefix="/receipt-folder")
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"",
response_model=list[ReceiptFolderItemRead],
summary="查询票据夹列表",
description="返回当前登录用户上传并持久化的票据列表。",
)
def list_receipts(
current_user: CurrentUser,
status_filter: Annotated[str, Query(alias="status")] = "all",
) -> list[ReceiptFolderItemRead]:
return ReceiptFolderService().list_receipts(
current_user=current_user,
status_filter=status_filter,
)
@router.get(
"/{receipt_id}",
response_model=ReceiptFolderDetailRead,
summary="读取票据详情",
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据不存在。"}},
)
def get_receipt(receipt_id: str, current_user: CurrentUser) -> ReceiptFolderDetailRead:
try:
return ReceiptFolderService().get_receipt(receipt_id, current_user)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt not found") from exc
@router.patch(
"/{receipt_id}",
response_model=ReceiptFolderDetailRead,
summary="更新票据基础识别信息",
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据不存在。"}},
)
def update_receipt(
receipt_id: str,
payload: ReceiptFolderUpdate,
current_user: CurrentUser,
) -> ReceiptFolderDetailRead:
try:
return ReceiptFolderService().update_receipt(
receipt_id=receipt_id,
payload=payload,
current_user=current_user,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt not found") from exc
@router.delete(
"/{receipt_id}",
response_model=ReceiptFolderDeleteResponse,
summary="删除票据",
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据不存在。"}},
)
def delete_receipt(receipt_id: str, current_user: CurrentUser) -> ReceiptFolderDeleteResponse:
try:
return ReceiptFolderService().delete_receipt(receipt_id=receipt_id, current_user=current_user)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt not found") from exc
@router.get(
"/{receipt_id}/preview",
summary="预览票据原始文件",
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据预览不存在。"}},
)
def preview_receipt(receipt_id: str, current_user: CurrentUser) -> FileResponse:
try:
file_path, media_type, file_name = ReceiptFolderService().resolve_preview(receipt_id, current_user)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt preview not found") from exc
return FileResponse(file_path, media_type=media_type, filename=file_name)
@router.get(
"/{receipt_id}/source",
summary="读取票据源文件",
responses={status.HTTP_404_NOT_FOUND: {"model": ErrorResponse, "description": "票据源文件不存在。"}},
)
def source_receipt(receipt_id: str, current_user: CurrentUser) -> FileResponse:
try:
file_path, media_type, file_name = ReceiptFolderService().resolve_source(receipt_id, current_user)
except FileNotFoundError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Receipt source not found") from exc
return FileResponse(file_path, media_type=media_type, filename=file_name)

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
@@ -372,6 +372,7 @@ async def upload_expense_claim_item_attachment(
file: Annotated[UploadFile, File(description="待上传的附件文件。")],
db: DbSession,
current_user: CurrentUser,
receipt_id: Annotated[str | None, Form(description="可选,来源于票据夹的持久化票据 ID。")] = None,
) -> ExpenseClaimAttachmentActionResponse:
service = ExpenseClaimService(db)
try:
@@ -382,6 +383,7 @@ async def upload_expense_claim_item_attachment(
content=await file.read(),
media_type=file.content_type,
current_user=current_user,
source_receipt_id=receipt_id or "",
)
except LookupError as error:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(error)) from error