feat: 新增票据夹模块并优化 OCR 与员工画像服务
后端新增票据夹端点、数据模型和服务模块,优化 OCR 端点 Schema 和附件操作逻辑,完善员工行为画像服务和辅助函数, 前端新增票据夹视图和服务层,优化文档中心样式和侧边栏导 航,完善员工画像详情弹窗和权限控制,补充单元测试。
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
108
server/src/app/api/v1/endpoints/receipt_folder.py
Normal file
108
server/src/app/api/v1/endpoints/receipt_folder.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.api.v1.endpoints.knowledge import router as knowledge_router
|
||||
from app.api.v1.endpoints.ocr import router as ocr_router
|
||||
from app.api.v1.endpoints.ontology import router as ontology_router
|
||||
from app.api.v1.endpoints.orchestrator import router as orchestrator_router
|
||||
from app.api.v1.endpoints.receipt_folder import router as receipt_folder_router
|
||||
from app.api.v1.endpoints.reimbursements import router as reimbursements_router
|
||||
from app.api.v1.endpoints.settings import router as settings_router
|
||||
from app.api.v1.endpoints.system_logs import router as system_logs_router
|
||||
@@ -29,6 +30,7 @@ router.include_router(knowledge_router, tags=["knowledge"])
|
||||
router.include_router(ocr_router, tags=["ocr"])
|
||||
router.include_router(ontology_router, tags=["ontology"])
|
||||
router.include_router(orchestrator_router, tags=["orchestrator"])
|
||||
router.include_router(receipt_folder_router, tags=["receipt-folder"])
|
||||
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
||||
router.include_router(employee_profiles_router, tags=["employee-profiles"])
|
||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||
|
||||
Reference in New Issue
Block a user