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

@@ -23,56 +23,79 @@
## 阶段三:后端票据资产层
- [ ] 新增 `schemas/receipt_folder.py`,定义列表项、详情、字段更新和删除响应。[CONCEPT: 后端]
- [x] 新增 `schemas/receipt_folder.py`,定义列表项、详情、字段更新和删除响应。[CONCEPT: 后端]
证据:已新增 `ReceiptFolderItemRead``ReceiptFolderDetailRead``ReceiptFolderUpdate``ReceiptFolderDeleteResponse`
- [ ] 新增 `services/receipt_folder.py`,负责源文件保存、元数据读写、预览解析、列表过滤和安全路径校验。[CONCEPT: 票据持久化]
- [x] 新增 `services/receipt_folder.py`,负责源文件保存、元数据读写、预览解析、列表过滤和安全路径校验。[CONCEPT: 票据持久化]
证据:`ReceiptFolderService` 已覆盖 OCR 批量持久化、已关联附件同步、详情更新、预览/源文件解析与目录安全校验。
- [ ] 新增 `api/v1/endpoints/receipt_folder.py`,暴露列表、详情、更新、删除、预览和源文件接口。[CONCEPT: 后端]
- [x] 新增 `api/v1/endpoints/receipt_folder.py`,暴露列表、详情、更新、删除、预览和源文件接口。[CONCEPT: 后端]
证据:已提供 `GET/PATCH/DELETE /receipt-folder/{receipt_id}``preview/source` 文件接口。
- [ ]`api/v1/router.py` 注册票据夹接口。[CONCEPT: 后端]
- [x]`api/v1/router.py` 注册票据夹接口。[CONCEPT: 后端]
证据:已 include `receipt_folder_router`
- [ ] 改造 `/ocr/recognize`OCR 后保存源文件并把 `receipt_id` 等可选字段带回前端。[CONCEPT: OCR 改造]
- [x] 改造 `/ocr/recognize`OCR 后保存源文件并把 `receipt_id` 等可选字段带回前端。[CONCEPT: OCR 改造]
证据:`OcrRecognizeDocumentRead` 已补充 `receipt_id``receipt_status``receipt_preview_url``receipt_source_url`;来源于票据夹的 `receipt_ids` 会复用原票据,避免重复入库。
## 阶段四:前端票据夹页面
- [ ] 新增 `services/receiptFolder.js`,封装票据夹接口和 Blob 文件读取。[CONCEPT: 前端]
- [x] 新增 `services/receiptFolder.js`,封装票据夹接口和 Blob 文件读取。[CONCEPT: 前端]
证据:已封装列表、详情、更新、删除、文件读取和 `buildReceiptFile`
- [ ] 新增 `ReceiptFolderView.vue`,实现列表、状态页签、搜索、一键关联入口和详情切换。[CONCEPT: 列表]
- [x] 新增 `ReceiptFolderView.vue`,实现列表、状态页签、搜索、一键关联入口和详情切换。[CONCEPT: 列表]
证据:页面已包含未关联/已关联页签、搜索、表格、详情、编辑、预览和删除动作。
- [ ] 新增 `receipt-folder-view.css`,复用单据中心紧凑企业级视觉,避免继续拉大现有 `DocumentsCenterView.vue`。[CONCEPT: 列表]
- [x] 新增 `receipt-folder-view.css`,复用单据中心紧凑企业级视觉,避免继续拉大现有 `DocumentsCenterView.vue`。[CONCEPT: 列表]
证据:票据夹样式独立落在 `assets/styles/views/receipt-folder-view.css`,核心文件均未超过 800 行。
- [ ]`useNavigation.js` 增加 `receiptFolder`,并放在 `documents` 后面。[CONCEPT: 前端]
- [x]`useNavigation.js` 增加 `receiptFolder`,并放在 `documents` 后面。[CONCEPT: 前端]
证据:`appViews``navItems``receiptFolder` 均紧跟 `documents`
- [ ]`accessControl.js` 增加默认可见权限和默认路由顺序。[CONCEPT: 前端]
- [x]`accessControl.js` 增加默认可见权限和默认路由顺序。[CONCEPT: 前端]
证据:已加入 `DEFAULT_APP_VIEW_ORDER``ALWAYS_VISIBLE_VIEWS`
- [ ]`AppShellRouteView.vue` 渲染票据夹页面,并让页面可打开报销对话。[CONCEPT: 一键关联票据]
- [x]`AppShellRouteView.vue` 渲染票据夹页面,并让页面可打开报销对话。[CONCEPT: 一键关联票据]
证据:已接入 `ReceiptFolderView` 并转发 `open-assistant``openSmartEntry`
## 阶段五:一键关联流程
- [ ] 实现未关联票据多选弹窗第一步。[CONCEPT: 一键关联票据]
- [x] 实现未关联票据多选弹窗第一步。[CONCEPT: 一键关联票据]
证据:`ElDialog` 第一阶段使用 `ElCheckboxGroup` 多选未关联票据。
- [ ] 实现未提交草稿选择和“新建报销单”选择第二步。[CONCEPT: 一键关联票据]
- [x] 实现未提交草稿选择和“新建报销单”选择第二步。[CONCEPT: 一键关联票据]
证据:第二阶段读取草稿报销单,并保留 `新建报销单` 选项。
- [ ] 从票据源文件接口取回 Blob 并构造 `File` 对象传给报销对话。[CONCEPT: 对话衔接]
- [x] 从票据源文件接口取回 Blob 并构造 `File` 对象传给报销对话。[CONCEPT: 对话衔接]
证据:`buildReceiptFile``source_url` 读取 Blob 并生成带 `receiptId``File`
- [ ] 选择已有草稿时,打开对话并带入草稿单号和关联提示。[CONCEPT: 一键关联票据]
- [x] 选择已有草稿时,打开对话并带入草稿单号和关联提示。[CONCEPT: 一键关联票据]
证据:选择草稿后以 `source: 'detail'``request` 和关联 prompt 打开报销对话;附件上传会携带 `receipt_id` 并回写原票据为已关联。
- [ ] 选择新建报销单时,打开对话并带入基于票据新建的提示。[CONCEPT: 一键关联票据]
- [x] 选择新建报销单时,打开对话并带入基于票据新建的提示。[CONCEPT: 一键关联票据]
证据:新建路径以 `source: 'receipt-folder'` 携带票据文件和新建 prompt。
## 阶段六:测试与验证
- [ ] 补充后端票据夹服务和接口测试,超时时间控制在 60s 内。[CONCEPT: 测试方案]
- [x] 补充后端票据夹服务和接口测试,超时时间控制在 60s 内。[CONCEPT: 测试方案]
证据:`docker exec x-financial-main ... pytest server/tests/test_ocr_endpoints.py server/tests/test_reimbursement_endpoints.py -q` 通过8 passed耗时 11.41s。
- [ ] 补充前端导航和票据夹视图模型测试。[CONCEPT: 测试方案]
- [x] 补充前端导航和票据夹视图模型测试。[CONCEPT: 测试方案]
证据:`navigation-route-resolution.test.mjs` 已覆盖路由顺序,新增 `receipt-folder-view.test.mjs` 覆盖视图关键能力。
- [ ] 运行前端构建或定向测试。[CONCEPT: 指标与验收]
- [x] 运行前端构建或定向测试。[CONCEPT: 指标与验收]
证据:`node web/tests/navigation-route-resolution.test.mjs``node web/tests/receipt-folder-view.test.mjs``npm.cmd run build` 均通过。
- [ ] 在 Docker `x-financial-main``/app` 内运行后端定向测试。[CONCEPT: 测试方案]
- [x] 在 Docker `x-financial-main``/app` 内运行后端定向测试。[CONCEPT: 测试方案]
证据:`pytest server/tests/test_ocr_endpoints.py server/tests/test_reimbursement_endpoints.py -q` 在容器内通过8 passed。
- [ ] 手动核对侧边栏位置、列表密度、详情预览和关联弹窗。[CONCEPT: 指标与验收]
- [x] 手动核对侧边栏位置、列表密度、详情预览和关联弹窗。[CONCEPT: 指标与验收]
证据:内置浏览器打开 `http://localhost:5173/app/receiptFolder`,侧边栏中“票据夹”位于“单据中心”下方,未关联/已关联页签与空态渲染正常,控制台无相关错误。
## 阶段七:收口
- [ ] 回看 `CONCEPT.md` 验收标准,确认已实现项均有证据。[CONCEPT: 指标与验收]
- [x] 回看 `CONCEPT.md` 验收标准,确认已实现项均有证据。[CONCEPT: 指标与验收]
证据OCR 持久化、列表/详情/预览、侧边栏位置、一键关联入口和文件行数约束均已覆盖。
- [ ] 更新本 TODO 的完成状态和验证记录。[CONCEPT: 测试方案]
- [x] 更新本 TODO 的完成状态和验证记录。[CONCEPT: 测试方案]
证据:本文件已补充完成勾选和验证命令记录。

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

View File

@@ -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"])

View File

@@ -39,6 +39,10 @@ class OcrRecognizeDocumentRead(BaseModel):
)
preview_kind: str = Field(default="", description="预览类型PDF 转图后通常为 image。")
preview_data_url: str = Field(default="", description="用于前端展示的图片预览 data URL。")
receipt_id: str = Field(default="", description="票据夹中的持久化票据 ID。")
receipt_status: str = Field(default="", description="票据夹关联状态unlinked / linked。")
receipt_preview_url: str = Field(default="", description="票据夹预览接口地址。")
receipt_source_url: str = Field(default="", description="票据夹原始文件接口地址。")
warnings: list[str] = Field(default_factory=list, description="该文件的识别提示或警告。")
lines: list[OcrRecognizeLineRead] = Field(default_factory=list, description="逐行识别结果。")

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class ReceiptFolderFieldRead(BaseModel):
key: str = ""
label: str = ""
value: str = ""
class ReceiptFolderItemRead(BaseModel):
id: str
file_name: str
media_type: str = "application/octet-stream"
size_bytes: int = 0
status: str = "unlinked"
status_label: str = "未关联"
document_type: str = "other"
document_type_label: str = "其他单据"
scene_code: str = "other"
scene_label: str = "其他票据"
summary: str = ""
amount: str = ""
document_date: str = ""
merchant_name: str = ""
avg_score: float = 0.0
uploaded_at: datetime | None = None
linked_at: datetime | None = None
linked_claim_id: str = ""
linked_claim_no: str = ""
previewable: bool = False
preview_kind: str = ""
preview_url: str = ""
source_url: str = ""
warnings: list[str] = Field(default_factory=list)
class ReceiptFolderDetailRead(ReceiptFolderItemRead):
engine: str = ""
model: str = ""
ocr_text: str = ""
line_count: int = 0
page_count: int = 1
classification_confidence: float = 0.0
classification_evidence: list[str] = Field(default_factory=list)
fields: list[ReceiptFolderFieldRead] = Field(default_factory=list)
raw_meta: dict[str, Any] = Field(default_factory=dict)
class ReceiptFolderUpdate(BaseModel):
document_type: str | None = None
document_type_label: str | None = None
scene_code: str | None = None
scene_label: str | None = None
summary: str | None = None
amount: str | None = None
document_date: str | None = None
merchant_name: str | None = None
fields: list[ReceiptFolderFieldRead] | None = None
class ReceiptFolderDeleteResponse(BaseModel):
message: str
receipt_id: str

View File

@@ -0,0 +1,176 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
from app.algorithem.employee_behavior_profile import (
LEVEL_LABELS,
PROFILE_LABELS,
ProfileComponent,
evaluate_weighted_profile,
score_by_bands,
)
from app.algorithem.employee_behavior_profile_tags import build_profile_radar, build_profile_tags
from app.models.agent_run import AgentRun
from app.schemas.employee_profile import EmployeeProfileLatestRead, EmployeeProfileRead
from app.services.employee_behavior_profile_helpers import EmployeeBehaviorProfileMetricHelpers
class AccountBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
def __init__(self, db: Session) -> None:
self.db = db
def get_latest_account_profile(
self,
*,
account_id: str,
account_name: str,
identifiers: set[str],
scene: str,
window_days: int,
expense_type_scope: str,
) -> EmployeeProfileLatestRead:
if scene != "operations":
return EmployeeProfileLatestRead(
employee_id=account_id,
employee_name=account_name,
scene=scene,
window_days=window_days,
expense_type_scope=expense_type_scope,
empty_reason="当前账号未匹配员工目录,无法形成审批场景员工画像。",
)
runs = self._fetch_account_runs(identifiers, datetime.now(UTC) - timedelta(days=window_days))
if not runs:
return EmployeeProfileLatestRead(
employee_id=account_id,
employee_name=account_name,
scene=scene,
window_days=window_days,
expense_type_scope=expense_type_scope,
empty_reason="当前账号暂无可统计的智能体运行记录。",
)
result = self._calculate_account_ai_usage_profile(
runs=runs,
window_days=window_days,
expense_type_scope=expense_type_scope,
)
payload = {
"profile_type": result.profile_type,
"profile_label": result.profile_label,
"score": result.profile_score,
"level": result.profile_level,
"metrics": result.metrics,
"top_contributors": result.top_contributors(),
}
tags = build_profile_tags([payload], scene=scene)
radar = build_profile_radar([payload], tags, scene=scene)
return EmployeeProfileLatestRead(
employee_id=account_id,
employee_name=account_name,
scene=scene,
window_days=window_days,
expense_type_scope=expense_type_scope,
calculated_at=datetime.now(UTC),
review_priority_score=0,
review_priority_level="normal",
review_priority_label=LEVEL_LABELS["normal"],
profiles=[
EmployeeProfileRead(
profile_type=payload["profile_type"],
profile_label=PROFILE_LABELS.get(payload["profile_type"], payload["profile_type"]),
score=payload["score"],
level=payload["level"],
level_label=LEVEL_LABELS.get(payload["level"], payload["level"]),
metrics=payload["metrics"],
top_contributors=payload["top_contributors"],
)
],
profile_tags=tags,
radar=radar,
)
def _calculate_account_ai_usage_profile(
self,
*,
runs: list[AgentRun],
window_days: int,
expense_type_scope: str,
):
tool_calls = [tool for run in runs for tool in run.tool_calls]
failed_calls = [
tool for tool in tool_calls if str(tool.status or "").lower() not in {"success", "ok"}
]
estimated_tokens = self._estimate_tokens(runs)
duration_ms = self._sum_agent_run_duration_ms(runs)
token_mode = "estimated_token_count" if estimated_tokens else "unavailable"
return evaluate_weighted_profile(
"ai_usage",
[
ProfileComponent(
"ai_call_count_score",
"AI 调用次数",
score_by_bands(len(runs), [(0, 0), (3, 25), (10, 65), (20, 100)]),
len(runs),
"",
Decimal("0.25"),
),
ProfileComponent(
"token_cost_score",
"Token 使用强度",
score_by_bands(
estimated_tokens, [(0, 0), (2000, 25), (8000, 65), (20000, 100)]
),
estimated_tokens,
"tokens",
Decimal("0.25"),
),
ProfileComponent(
"ai_generated_claim_ratio_score",
"AI 生成申请比例",
score_by_bands(len(runs), [(0, 0), (2, 20), (8, 60), (16, 90)]),
len(runs),
"",
Decimal("0.20"),
),
ProfileComponent(
"failed_ai_call_score",
"AI 调用失败",
score_by_bands(len(failed_calls), [(0, 0), (1, 35), (3, 80)]),
len(failed_calls),
"",
Decimal("0.10"),
),
],
metrics={
"window_days": window_days,
"expense_type_scope": expense_type_scope,
"peer_sample_size": 0,
"ai_run_count": len(runs),
"tool_call_count": len(tool_calls),
"failed_tool_call_count": len(failed_calls),
"token_count_mode": token_mode,
"estimated_token_count": estimated_tokens,
"exact_token_count": None,
"ai_run_duration_ms": duration_ms,
"ai_run_duration_mode": "elapsed_or_tool_call_fallback",
},
)
def _fetch_account_runs(self, identifiers: set[str], cutoff: datetime) -> list[AgentRun]:
normalized = {item for item in identifiers if str(item or "").strip()}
if not normalized:
return []
stmt = (
select(AgentRun)
.options(selectinload(AgentRun.tool_calls))
.where(AgentRun.started_at >= cutoff, AgentRun.user_id.in_(normalized))
)
return list(self.db.scalars(stmt).all())

View File

@@ -171,6 +171,22 @@ class EmployeeBehaviorProfileMetricHelpers:
total += max(0, len(text) // 4)
return total
def _sum_agent_run_duration_ms(self, runs: list[AgentRun]) -> int:
return sum(self._agent_run_duration_ms(run) for run in runs)
def _agent_run_duration_ms(self, run: AgentRun) -> int:
if run.started_at is not None and run.finished_at is not None:
try:
if run.finished_at > run.started_at:
return min(
int((run.finished_at - run.started_at).total_seconds() * 1000),
24 * 60 * 60 * 1000,
)
except TypeError:
pass
return sum(max(0, int(tool.duration_ms or 0)) for tool in run.tool_calls)
@staticmethod
def _is_missing_value(value: Any) -> bool:
text = str(value or "").strip()

View File

@@ -466,6 +466,7 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
tool for tool in tool_calls if str(tool.status or "").lower() not in {"success", "ok"}
]
estimated_tokens = self._estimate_tokens(runs)
duration_ms = self._sum_agent_run_duration_ms(runs)
override_score = 0
token_mode = "estimated_token_count" if estimated_tokens else "unavailable"
@@ -524,6 +525,8 @@ class EmployeeBehaviorProfileService(EmployeeBehaviorProfileMetricHelpers):
"token_count_mode": token_mode,
"estimated_token_count": estimated_tokens,
"exact_token_count": None,
"ai_run_duration_ms": duration_ms,
"ai_run_duration_mode": "elapsed_or_tool_call_fallback",
},
)

View File

@@ -108,6 +108,7 @@ from app.services.expense_rule_runtime import (
resolve_document_type_label,
)
from app.services.ocr import OcrService
from app.services.receipt_folder import ReceiptFolderService
class ExpenseClaimAttachmentOperationsMixin:
@@ -120,6 +121,7 @@ class ExpenseClaimAttachmentOperationsMixin:
content: bytes,
media_type: str | None,
current_user: CurrentUserContext,
source_receipt_id: str = "",
) -> dict[str, Any] | None:
claim, item = self._get_claim_item_or_raise(
claim_id=claim_id,
@@ -240,6 +242,16 @@ class ExpenseClaimAttachmentOperationsMixin:
"ocr_warnings": [str(item) for item in getattr(ocr_document, "warnings", []) or []],
}
self._attachment_storage.write_meta(file_path, meta)
ReceiptFolderService().save_linked_attachment(
file_path=file_path,
media_type=resolved_media_type,
document=ocr_document,
current_user=current_user,
claim_id=claim.id,
claim_no=claim.claim_no,
item_id=item.id,
source_receipt_id=source_receipt_id,
)
self._sync_claim_from_items(claim)
self.db.commit()

View File

@@ -0,0 +1,532 @@
from __future__ import annotations
import json
import mimetypes
import re
import shutil
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from app.api.deps import CurrentUserContext
from app.core.config import get_settings
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.receipt_folder import (
ReceiptFolderDeleteResponse,
ReceiptFolderDetailRead,
ReceiptFolderFieldRead,
ReceiptFolderItemRead,
ReceiptFolderUpdate,
)
from app.services.expense_claim_attachment_presentation import ExpenseClaimAttachmentPresentation
from app.services.ocr import SUPPORTED_SUFFIXES
class ReceiptFolderService:
def __init__(self) -> None:
self.root = (get_settings().resolved_storage_root_dir / "receipt_folder").resolve()
def persist_ocr_batch(
self,
*,
files: list[tuple[str, bytes, str | None]],
result: OcrRecognizeBatchRead,
current_user: CurrentUserContext,
receipt_ids: list[str] | None = None,
) -> OcrRecognizeBatchRead:
documents = list(result.documents or [])
enriched: list[OcrRecognizeDocumentRead] = []
for index, document in enumerate(documents):
if index >= len(files):
enriched.append(document)
continue
existing_receipt = self._resolve_existing_item(
receipt_ids[index] if receipt_ids and index < len(receipt_ids) else "",
current_user,
)
if existing_receipt is not None:
enriched.append(
document.model_copy(
update={
"receipt_id": existing_receipt.id,
"receipt_status": existing_receipt.status,
"receipt_preview_url": existing_receipt.preview_url,
"receipt_source_url": existing_receipt.source_url,
}
)
)
continue
filename, content, media_type = files[index]
if not self._should_persist_source(filename, content):
enriched.append(document)
continue
receipt = self.save_receipt(
filename=filename,
content=content,
media_type=media_type or document.media_type,
document=document,
current_user=current_user,
)
enriched.append(
document.model_copy(
update={
"receipt_id": receipt.id,
"receipt_status": receipt.status,
"receipt_preview_url": receipt.preview_url,
"receipt_source_url": receipt.source_url,
}
)
)
return result.model_copy(update={"documents": enriched})
def save_receipt(
self,
*,
filename: str,
content: bytes,
media_type: str | None,
document: Any | None,
current_user: CurrentUserContext,
linked_claim_id: str = "",
linked_claim_no: str = "",
linked_item_id: str = "",
) -> ReceiptFolderItemRead:
owner_key = self._owner_key(current_user)
receipt_id = str(uuid4())
receipt_dir = self._owner_root(owner_key) / receipt_id
receipt_dir.mkdir(parents=True, exist_ok=True)
normalized_name = self.normalize_filename(filename)
source_path = receipt_dir / normalized_name
source_path.write_bytes(content)
resolved_media_type = self.resolve_media_type(normalized_name, media_type)
preview_meta = self._write_preview_asset(
receipt_dir=receipt_dir,
source_path=source_path,
media_type=resolved_media_type,
document=document,
)
now = datetime.now(UTC)
linked = bool(str(linked_claim_id or "").strip())
meta = {
"id": receipt_id,
"owner_key": owner_key,
"file_name": normalized_name,
"source_file_name": normalized_name,
"media_type": resolved_media_type,
"size_bytes": len(content),
"uploaded_at": now.isoformat(),
"status": "linked" if linked else "unlinked",
"linked_claim_id": str(linked_claim_id or "").strip(),
"linked_claim_no": str(linked_claim_no or "").strip(),
"linked_item_id": str(linked_item_id or "").strip(),
"linked_at": now.isoformat() if linked else "",
**self._build_document_meta(document),
**preview_meta,
}
self._write_meta(receipt_dir, meta)
return self._build_item(meta)
def save_linked_attachment(
self,
*,
file_path: Path,
media_type: str,
document: Any | None,
current_user: CurrentUserContext,
claim_id: str,
claim_no: str,
item_id: str,
source_receipt_id: str = "",
) -> ReceiptFolderItemRead | None:
if not file_path.exists() or not file_path.is_file():
return None
if str(source_receipt_id or "").strip():
try:
return self.mark_receipt_linked(
receipt_id=source_receipt_id,
current_user=current_user,
claim_id=claim_id,
claim_no=claim_no,
item_id=item_id,
)
except FileNotFoundError:
pass
storage_root = get_settings().resolved_storage_root_dir
try:
file_path.resolve().relative_to(storage_root)
except ValueError:
return None
return self.save_receipt(
filename=file_path.name,
content=file_path.read_bytes(),
media_type=media_type,
document=document,
current_user=current_user,
linked_claim_id=claim_id,
linked_claim_no=claim_no,
linked_item_id=item_id,
)
def mark_receipt_linked(
self,
*,
receipt_id: str,
current_user: CurrentUserContext,
claim_id: str,
claim_no: str,
item_id: str,
) -> ReceiptFolderItemRead:
owner_key = self._owner_key(current_user)
receipt_dir = self._receipt_dir(owner_key, receipt_id)
meta = self._read_meta(receipt_dir)
meta["status"] = "linked"
meta["linked_claim_id"] = str(claim_id or "").strip()
meta["linked_claim_no"] = str(claim_no or "").strip()
meta["linked_item_id"] = str(item_id or "").strip()
meta["linked_at"] = datetime.now(UTC).isoformat()
self._write_meta(receipt_dir, meta)
return self._build_item(meta)
def list_receipts(
self,
*,
current_user: CurrentUserContext,
status_filter: str = "all",
) -> list[ReceiptFolderItemRead]:
status_filter = str(status_filter or "all").strip().lower()
items = [
self._build_item(meta)
for meta in self._iter_owner_meta(self._owner_key(current_user))
if self._matches_status(meta, status_filter)
]
return sorted(items, key=lambda item: item.uploaded_at or datetime.min.replace(tzinfo=UTC), reverse=True)
def get_receipt(self, receipt_id: str, current_user: CurrentUserContext) -> ReceiptFolderDetailRead:
meta = self._read_receipt_meta(receipt_id, current_user)
item = self._build_item(meta)
return ReceiptFolderDetailRead(
**item.model_dump(),
engine=str(meta.get("engine") or ""),
model=str(meta.get("model") or ""),
ocr_text=str(meta.get("ocr_text") or ""),
line_count=int(meta.get("ocr_line_count") or 0),
page_count=max(1, int(meta.get("page_count") or 1)),
classification_confidence=float(meta.get("ocr_classification_confidence") or 0.0),
classification_evidence=[
str(value) for value in list(meta.get("ocr_classification_evidence") or []) if str(value).strip()
],
fields=self._resolve_fields(meta),
raw_meta=meta,
)
def update_receipt(
self,
*,
receipt_id: str,
payload: ReceiptFolderUpdate,
current_user: CurrentUserContext,
) -> ReceiptFolderDetailRead:
owner_key = self._owner_key(current_user)
receipt_dir = self._receipt_dir(owner_key, receipt_id)
meta = self._read_meta(receipt_dir)
updates = payload.model_dump(exclude_unset=True)
for key in ("document_type", "document_type_label", "scene_code", "scene_label", "summary"):
if key in updates and updates[key] is not None:
meta[key] = str(updates[key] or "").strip()
editable = dict(meta.get("editable_fields") or {})
for key in ("amount", "document_date", "merchant_name"):
if key in updates and updates[key] is not None:
editable[key] = str(updates[key] or "").strip()
if "fields" in updates and updates["fields"] is not None:
meta["document_fields"] = [
field.model_dump() if isinstance(field, ReceiptFolderFieldRead) else dict(field)
for field in payload.fields or []
]
meta["editable_fields"] = editable
meta["updated_at"] = datetime.now(UTC).isoformat()
self._write_meta(receipt_dir, meta)
return self.get_receipt(receipt_id, current_user)
def delete_receipt(
self,
*,
receipt_id: str,
current_user: CurrentUserContext,
) -> ReceiptFolderDeleteResponse:
owner_key = self._owner_key(current_user)
receipt_dir = self._receipt_dir(owner_key, receipt_id)
shutil.rmtree(receipt_dir)
return ReceiptFolderDeleteResponse(message="票据已删除。", receipt_id=receipt_id)
def resolve_source(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
meta = self._read_receipt_meta(receipt_id, current_user)
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
file_name = str(meta.get("source_file_name") or meta.get("file_name") or "").strip()
path = self._assert_child(receipt_dir / file_name)
if not path.exists():
raise FileNotFoundError("Receipt source not found")
media_type = self.resolve_media_type(path.name, str(meta.get("media_type") or ""))
return path, media_type, str(meta.get("file_name") or path.name)
def resolve_preview(self, receipt_id: str, current_user: CurrentUserContext) -> tuple[Path, str, str]:
meta = self._read_receipt_meta(receipt_id, current_user)
receipt_dir = self._receipt_dir(self._owner_key(current_user), receipt_id)
preview_name = str(meta.get("preview_file_name") or "").strip()
if preview_name:
preview_path = self._assert_child(receipt_dir / preview_name)
if preview_path.exists():
return (
preview_path,
self.resolve_media_type(preview_path.name, str(meta.get("preview_media_type") or "")),
preview_path.name,
)
source_path, source_media_type, source_name = self.resolve_source(receipt_id, current_user)
if self._is_previewable(source_media_type):
return source_path, source_media_type, source_name
raise FileNotFoundError("Receipt preview not found")
@staticmethod
def normalize_filename(filename: str | None) -> str:
normalized = Path(str(filename or "").strip()).name
normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", normalized).strip("._")
return normalized or "receipt.bin"
@staticmethod
def resolve_media_type(filename: str, fallback: str | None = None) -> str:
return str(mimetypes.guess_type(filename)[0] or fallback or "application/octet-stream")
def _owner_root(self, owner_key: str) -> Path:
return self._assert_child(self.root / owner_key)
def _receipt_dir(self, owner_key: str, receipt_id: str) -> Path:
normalized = str(receipt_id or "").strip()
if not re.fullmatch(r"[0-9a-fA-F-]{32,36}", normalized):
raise FileNotFoundError("Receipt not found")
path = self._assert_child(self._owner_root(owner_key) / normalized)
if not path.exists() or not path.is_dir():
raise FileNotFoundError("Receipt not found")
return path
def _assert_child(self, path: Path) -> Path:
self.root.mkdir(parents=True, exist_ok=True)
resolved = path.resolve()
try:
resolved.relative_to(self.root)
except ValueError as exc:
raise FileNotFoundError("Receipt path is invalid") from exc
return resolved
@staticmethod
def _owner_key(current_user: CurrentUserContext) -> str:
raw = str(current_user.username or current_user.name or "anonymous").strip().lower()
normalized = re.sub(r"[^\w.\-\u4e00-\u9fff]+", "_", raw).strip("._")
return normalized or "anonymous"
@staticmethod
def _should_persist_source(filename: str, content: bytes) -> bool:
if not content:
return False
return Path(str(filename or "")).suffix.lower() in SUPPORTED_SUFFIXES
def _write_preview_asset(
self,
*,
receipt_dir: Path,
source_path: Path,
media_type: str,
document: Any | None,
) -> dict[str, Any]:
preview_data_url = str(getattr(document, "preview_data_url", "") or "").strip()
decoded = ExpenseClaimAttachmentPresentation.decode_data_url(preview_data_url)
if decoded is not None:
preview_media_type, preview_content = decoded
suffix = mimetypes.guess_extension(preview_media_type) or ".bin"
preview_name = f"preview{suffix}"
preview_path = receipt_dir / preview_name
preview_path.write_bytes(preview_content)
return {
"previewable": True,
"preview_kind": "image",
"preview_file_name": preview_name,
"preview_media_type": preview_media_type,
}
if self._is_previewable(media_type):
return {
"previewable": True,
"preview_kind": "image" if media_type.startswith("image/") else "pdf",
"preview_file_name": source_path.name,
"preview_media_type": media_type,
}
return {
"previewable": False,
"preview_kind": "",
"preview_file_name": "",
"preview_media_type": "",
}
@staticmethod
def _is_previewable(media_type: str) -> bool:
return str(media_type or "").startswith("image/") or str(media_type or "") == "application/pdf"
@staticmethod
def _build_document_meta(document: Any | None) -> dict[str, Any]:
fields = []
for field in list(getattr(document, "document_fields", []) or []):
if isinstance(field, dict):
fields.append(
{
"key": str(field.get("key") or "").strip(),
"label": str(field.get("label") or "").strip(),
"value": str(field.get("value") or "").strip(),
}
)
else:
fields.append(
{
"key": str(getattr(field, "key", "") or "").strip(),
"label": str(getattr(field, "label", "") or "").strip(),
"value": str(getattr(field, "value", "") or "").strip(),
}
)
fields = [field for field in fields if field["label"] and field["value"]]
return {
"engine": str(getattr(document, "engine", "") or ""),
"model": str(getattr(document, "model", "") or ""),
"ocr_text": str(getattr(document, "text", "") or ""),
"summary": str(getattr(document, "summary", "") or ""),
"ocr_avg_score": float(getattr(document, "avg_score", 0.0) or 0.0),
"ocr_line_count": int(getattr(document, "line_count", 0) or 0),
"page_count": int(getattr(document, "page_count", 1) or 1),
"document_type": str(getattr(document, "document_type", "") or "other"),
"document_type_label": str(getattr(document, "document_type_label", "") or "其他单据"),
"scene_code": str(getattr(document, "scene_code", "") or "other"),
"scene_label": str(getattr(document, "scene_label", "") or "其他票据"),
"ocr_classification_source": str(getattr(document, "classification_source", "") or ""),
"ocr_classification_confidence": float(getattr(document, "classification_confidence", 0.0) or 0.0),
"ocr_classification_evidence": [
str(value) for value in list(getattr(document, "classification_evidence", []) or []) if str(value).strip()
],
"document_fields": fields,
"editable_fields": {},
"ocr_warnings": [str(value) for value in list(getattr(document, "warnings", []) or []) if str(value).strip()],
}
def _iter_owner_meta(self, owner_key: str) -> list[dict[str, Any]]:
owner_root = self._owner_root(owner_key)
if not owner_root.exists():
return []
metas = []
for meta_path in owner_root.glob("*/meta.json"):
meta = self._read_meta(meta_path.parent)
if meta:
metas.append(meta)
return metas
def _read_receipt_meta(self, receipt_id: str, current_user: CurrentUserContext) -> dict[str, Any]:
return self._read_meta(self._receipt_dir(self._owner_key(current_user), receipt_id))
def _resolve_existing_item(
self,
receipt_id: str | None,
current_user: CurrentUserContext,
) -> ReceiptFolderItemRead | None:
normalized = str(receipt_id or "").strip()
if not normalized:
return None
try:
return self._build_item(self._read_receipt_meta(normalized, current_user))
except FileNotFoundError:
return None
@staticmethod
def _meta_path(receipt_dir: Path) -> Path:
return receipt_dir / "meta.json"
def _read_meta(self, receipt_dir: Path) -> dict[str, Any]:
meta_path = self._meta_path(receipt_dir)
if not meta_path.exists():
raise FileNotFoundError("Receipt not found")
try:
payload = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise FileNotFoundError("Receipt metadata not found") from exc
return payload if isinstance(payload, dict) else {}
def _write_meta(self, receipt_dir: Path, payload: dict[str, Any]) -> None:
self._meta_path(receipt_dir).write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
@staticmethod
def _matches_status(meta: dict[str, Any], status_filter: str) -> bool:
if status_filter in {"", "all"}:
return True
return str(meta.get("status") or "unlinked").strip().lower() == status_filter
def _build_item(self, meta: dict[str, Any]) -> ReceiptFolderItemRead:
receipt_id = str(meta.get("id") or "").strip()
status_value = str(meta.get("status") or "unlinked").strip() or "unlinked"
return ReceiptFolderItemRead(
id=receipt_id,
file_name=str(meta.get("file_name") or ""),
media_type=str(meta.get("media_type") or "application/octet-stream"),
size_bytes=int(meta.get("size_bytes") or 0),
status=status_value,
status_label="已关联" if status_value == "linked" else "未关联",
document_type=str(meta.get("document_type") or "other"),
document_type_label=str(meta.get("document_type_label") or "其他单据"),
scene_code=str(meta.get("scene_code") or "other"),
scene_label=str(meta.get("scene_label") or "其他票据"),
summary=str(meta.get("summary") or ""),
amount=self._resolve_editable_or_field(meta, "amount", labels=("金额", "价税合计", "票价")),
document_date=self._resolve_editable_or_field(meta, "document_date", labels=("日期", "开票日期", "乘车日期")),
merchant_name=self._resolve_editable_or_field(meta, "merchant_name", labels=("商户", "销售方", "收款方")),
avg_score=float(meta.get("ocr_avg_score") or 0.0),
uploaded_at=self._parse_datetime(meta.get("uploaded_at")),
linked_at=self._parse_datetime(meta.get("linked_at")),
linked_claim_id=str(meta.get("linked_claim_id") or ""),
linked_claim_no=str(meta.get("linked_claim_no") or ""),
previewable=bool(meta.get("previewable")),
preview_kind=str(meta.get("preview_kind") or ""),
preview_url=f"/receipt-folder/{receipt_id}/preview" if bool(meta.get("previewable")) and receipt_id else "",
source_url=f"/receipt-folder/{receipt_id}/source" if receipt_id else "",
warnings=[str(value) for value in list(meta.get("ocr_warnings") or []) if str(value).strip()],
)
def _resolve_fields(self, meta: dict[str, Any]) -> list[ReceiptFolderFieldRead]:
return [
ReceiptFolderFieldRead(
key=str(field.get("key") or ""),
label=str(field.get("label") or ""),
value=str(field.get("value") or ""),
)
for field in list(meta.get("document_fields") or [])
if isinstance(field, dict) and str(field.get("label") or "").strip()
]
def _resolve_editable_or_field(self, meta: dict[str, Any], key: str, *, labels: tuple[str, ...]) -> str:
editable = meta.get("editable_fields")
if isinstance(editable, dict):
value = str(editable.get(key) or "").strip()
if value:
return value
label_set = set(labels)
for field in self._resolve_fields(meta):
if field.label in label_set or field.key == key:
return field.value
return ""
@staticmethod
def _parse_datetime(value: Any) -> datetime | None:
raw = str(value or "").strip()
if not raw:
return None
try:
return datetime.fromisoformat(raw)
except ValueError:
return None

View File

@@ -264,6 +264,74 @@ def test_current_employee_profile_endpoint_resolves_login_user() -> None:
payload = response.json()
assert payload["employee_id"] == "emp-main"
assert {item["profile_type"] for item in payload["profiles"]} >= {"expense", "ai_usage"}
ai_profile = next(item for item in payload["profiles"] if item["profile_type"] == "ai_usage")
assert ai_profile["metrics"]["ai_run_duration_ms"] == 120
assert payload["profile_tags"]
assert payload["radar"]["dimensions"]
def test_current_admin_profile_endpoint_returns_account_usage_profile() -> None:
session_factory = build_session_factory()
with session_factory() as db:
seed_profile_data(db)
now = datetime.now(UTC)
for index in range(12):
run_id = f"run-admin-usage-{index}"
started_at = now - timedelta(days=1, minutes=index)
db.add(
AgentRun(
run_id=run_id,
agent="user_agent",
source="user_message",
user_id="admin",
status="success",
result_summary="管理员查看运行概览。",
started_at=started_at,
finished_at=started_at + timedelta(seconds=2),
tool_calls=[
AgentToolCall(
run_id=run_id,
tool_type="database",
tool_name="agent_runs.list",
request_json={"limit": 20},
response_json={"ok": True},
status="success",
duration_ms=120,
)
],
)
)
db.commit()
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
response = client.get(
"/api/v1/employee-profiles/me/latest",
params={
"scene": "operations",
"window_days": 90,
"expense_type_scope": "overall",
},
headers={"x-auth-username": "admin", "x-auth-name": "admin", "x-auth-is-admin": "true"},
)
assert response.status_code == 200
payload = response.json()
assert payload["employee_id"] == "admin"
assert payload["empty_reason"] == ""
assert [item["profile_type"] for item in payload["profiles"]] == ["ai_usage"]
metrics = payload["profiles"][0]["metrics"]
assert metrics["ai_run_count"] == 12
assert metrics["ai_run_duration_ms"] == 24000
assert payload["profile_tags"]
assert payload["radar"]["dimensions"]

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.core.config import get_settings
from app.db.base import Base
from app.main import create_app
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead, OcrRecognizeFieldRead, OcrRecognizeLineRead
@@ -35,7 +36,7 @@ def build_client() -> TestClient:
return TestClient(app)
def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch) -> None:
def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch, tmp_path) -> None:
def fake_recognize(
self,
files: list[tuple[str, bytes, str | None]],
@@ -76,21 +77,84 @@ def test_ocr_recognize_endpoint_returns_structured_payload(monkeypatch) -> None:
],
)
monkeypatch.setenv("STORAGE_ROOT_DIR", str(tmp_path / "storage"))
get_settings.cache_clear()
monkeypatch.setattr(OcrService, "recognize_files", fake_recognize)
try:
client = build_client()
auth_headers = {"x-auth-username": "pytest", "x-auth-name": "Py Test"}
response = client.post(
"/api/v1/ocr/recognize",
headers={"x-auth-username": "pytest", "x-auth-name": "Py Test"},
headers=auth_headers,
files=[("files", ("invoice.png", b"fake-image", "image/png"))],
)
assert response.status_code == 200
payload = response.json()
document = payload["documents"][0]
assert payload["engine"] == "paddleocr_mobile"
assert payload["success_count"] == 1
assert payload["documents"][0]["filename"] == "invoice.png"
assert payload["documents"][0]["summary"] == "增值税电子发票,金额 100 元。"
assert payload["documents"][0]["document_type"] == "vat_invoice"
assert payload["documents"][0]["document_type_label"] == "增值税发票"
assert payload["documents"][0]["document_fields"][0]["label"] == "金额"
assert document["filename"] == "invoice.png"
assert document["summary"] == "增值税电子发票,金额 100 元。"
assert document["document_type"] == "vat_invoice"
assert document["document_type_label"] == "增值税发票"
assert document["document_fields"][0]["label"] == "金额"
assert document["receipt_id"]
assert document["receipt_status"] == "unlinked"
assert document["receipt_preview_url"].endswith(f"/receipt-folder/{document['receipt_id']}/preview")
assert document["receipt_source_url"].endswith(f"/receipt-folder/{document['receipt_id']}/source")
receipt_id = document["receipt_id"]
list_response = client.get("/api/v1/receipt-folder?status=unlinked", headers=auth_headers)
assert list_response.status_code == 200
receipt_list = list_response.json()
assert len(receipt_list) == 1
assert receipt_list[0]["id"] == receipt_id
assert receipt_list[0]["amount"] == "100元"
repeated_response = client.post(
"/api/v1/ocr/recognize",
headers=auth_headers,
data={"receipt_ids": receipt_id},
files=[("files", ("invoice.png", b"fake-image", "image/png"))],
)
assert repeated_response.status_code == 200
repeated_document = repeated_response.json()["documents"][0]
assert repeated_document["receipt_id"] == receipt_id
all_receipts_response = client.get("/api/v1/receipt-folder?status=all", headers=auth_headers)
assert all_receipts_response.status_code == 200
assert len(all_receipts_response.json()) == 1
detail_response = client.get(f"/api/v1/receipt-folder/{receipt_id}", headers=auth_headers)
assert detail_response.status_code == 200
detail_payload = detail_response.json()
assert detail_payload["file_name"] == "invoice.png"
assert detail_payload["fields"][0]["label"] == "金额"
update_response = client.patch(
f"/api/v1/receipt-folder/{receipt_id}",
headers=auth_headers,
json={
"document_type_label": "电子发票",
"amount": "108元",
"fields": [{"key": "amount", "label": "金额", "value": "108元"}],
},
)
assert update_response.status_code == 200
assert update_response.json()["document_type_label"] == "电子发票"
assert update_response.json()["amount"] == "108元"
preview_response = client.get(f"/api/v1/receipt-folder/{receipt_id}/preview", headers=auth_headers)
assert preview_response.status_code == 200
assert preview_response.content == b"fake-image"
delete_response = client.delete(f"/api/v1/receipt-folder/{receipt_id}", headers=auth_headers)
assert delete_response.status_code == 200
assert delete_response.json()["receipt_id"] == receipt_id
deleted_response = client.get(f"/api/v1/receipt-folder/{receipt_id}", headers=auth_headers)
assert deleted_response.status_code == 404
finally:
get_settings.cache_clear()

View File

@@ -163,6 +163,7 @@
overflow: hidden;
}
.main.documents-main,
.main.receipt-folder-main,
.main.requests-main,
.main.approval-main,
.main.archive-main,
@@ -181,6 +182,7 @@
.workarea { min-height: 0; overflow: auto; padding: 24px; }
.workarea.requests-workarea,
.workarea.documents-workarea,
.workarea.receipt-folder-workarea,
.workarea.workbench-workarea,
.workarea.approval-workarea,
.workarea.archive-workarea,

View File

@@ -0,0 +1,495 @@
.status-tabs {
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
}
.status-tabs button {
position: relative;
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
}
.status-tabs button.active {
color: var(--theme-primary-active);
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 999px 999px 0 0;
background: var(--theme-primary);
}
.scope-tab-badge {
min-width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 850;
line-height: 1;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
}
.document-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.filter-set,
.document-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.list-search {
position: relative;
width: 280px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.list-search input::placeholder {
color: #8da0b4;
}
.list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
outline: none;
}
.filter-btn {
min-width: 120px;
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 9px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
}
.filter-btn:hover {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
}
.create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 4px;
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px var(--theme-primary-shadow);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.create-request-btn.secondary {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
box-shadow: none;
}
.create-request-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px var(--theme-primary-shadow);
filter: saturate(1.02);
}
.create-request-btn.secondary:hover {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.create-request-btn:disabled {
opacity: .55;
cursor: not-allowed;
transform: none;
filter: none;
}
.table-wrap {
min-height: 400px;
margin-top: 10px;
overflow-x: auto;
overflow-y: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
display: flex;
flex-direction: column;
}
.table-wrap.is-empty {
align-items: center;
justify-content: center;
}
.table-wrap table {
width: 100%;
align-self: flex-start;
}
.table-state {
width: 100%;
min-height: 260px;
display: grid;
place-items: center;
gap: 10px;
padding: 28px 20px;
text-align: center;
color: #64748b;
background: linear-gradient(180deg, #fcfffd 0%, #f5f9f7 100%);
}
.table-state .mdi {
font-size: 28px;
color: var(--theme-primary);
}
.table-state strong {
color: #0f172a;
font-size: 15px;
}
.table-state p {
max-width: 420px;
margin: 0;
font-size: 13px;
line-height: 1.6;
}
.table-state.error {
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
}
.table-state.error .mdi {
color: #ef4444;
}
.retry-btn {
height: 36px;
padding: 0 14px;
border: 1px solid #f1c5c5;
border-radius: 8px;
background: #fff;
color: #b91c1c;
font-size: 13px;
font-weight: 750;
}
table {
width: 100%;
min-width: 1420px;
border-collapse: collapse;
table-layout: fixed;
}
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03));
}
tbody tr:last-child td {
border-bottom: 0;
}
td small {
display: block;
margin-top: 4px;
overflow: hidden;
color: #7d8da1;
text-overflow: ellipsis;
white-space: nowrap;
}
.doc-id {
color: var(--theme-primary-active);
font-weight: 800;
}
.doc-kind-tag,
.type-tag,
.status-tag {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.doc-kind-tag {
min-height: 26px;
padding: 0 10px;
border-radius: 7px;
font-size: 12px;
font-weight: 800;
}
.doc-kind-tag.reimbursement {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.doc-kind-tag.application {
background: #eff6ff;
color: #2563eb;
}
.type-tag {
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.type-tag.travel,
.type-tag.hotel,
.type-tag.transport {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.type-tag.entertainment,
.type-tag.meal {
background: #fff7ed;
color: #ea580c;
}
.type-tag.office {
background: #eff6ff;
color: #2563eb;
}
.type-tag.meeting,
.type-tag.training {
background: #eef2ff;
color: #4f46e5;
}
.type-tag.other,
.type-tag.neutral {
background: #f8fafc;
color: #475569;
}
.status-tag {
min-height: 24px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 750;
}
.status-tag.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.status-tag.success,
.status-tag.archived,
.status-tag.completed {
border-color: var(--success-line);
background: var(--success-soft);
color: var(--success-active);
}
.status-tag.warning,
.status-tag.draft {
border-color: #fed7aa;
background: #fff7ed;
color: #f97316;
}
.status-tag.danger {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.status-tag.neutral {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
}
.pager button:hover:not(.active) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.pager button.active {
background: var(--theme-primary-active);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.pager button:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
.page-size-select {
width: 118px;
justify-self: end;
}
@media (max-width: 1200px) {
.document-toolbar,
.list-foot {
grid-template-columns: 1fr;
}
.document-toolbar {
align-items: stretch;
flex-direction: column;
}
.document-actions {
justify-content: flex-start;
}
}
@media (max-width: 760px) {
.status-tabs {
gap: 18px;
overflow-x: auto;
}
.filter-set,
.document-actions,
.list-search,
.filter-btn,
.page-size-select {
width: 100%;
}
.list-foot {
display: grid;
justify-items: stretch;
}
}

View File

@@ -15,135 +15,6 @@
overflow: hidden;
}
.status-tabs {
display: flex;
gap: 28px;
margin-top: 14px;
border-bottom: 1px solid #dbe4ee;
}
.status-tabs button {
position: relative;
min-height: 36px;
display: inline-flex;
align-items: center;
gap: 7px;
border: 0;
background: transparent;
color: #64748b;
font-size: 14px;
font-weight: 750;
}
.status-tabs button.active {
color: var(--theme-primary-active);
}
.status-tabs button.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 3px;
border-radius: 999px 999px 0 0;
background: var(--theme-primary);
}
.scope-tab-badge {
min-width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-weight: 850;
line-height: 1;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22);
}
.document-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
}
.filter-set,
.document-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.list-search {
position: relative;
width: 280px;
}
.list-search .mdi {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #64748b;
font-size: 15px;
}
.list-search input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.list-search input::placeholder {
color: #8da0b4;
}
.list-search input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
outline: none;
}
.filter-btn {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
padding: 0 14px;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #334155;
font-size: 14px;
font-weight: 750;
white-space: nowrap;
}
.filter-btn {
min-width: 120px;
justify-content: space-between;
}
.filter-btn:hover {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
}
.document-filter,
.date-range-filter {
position: relative;
@@ -287,43 +158,6 @@
background: #cbd5e1;
}
.create-request-btn {
min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 18px;
border: 0;
border-radius: 4px;
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active));
color: #fff;
font-size: 14px;
font-weight: 800;
white-space: nowrap;
box-shadow: 0 10px 24px var(--theme-primary-shadow);
transition: transform 160ms ease, box-shadow 160ms ease, filter 160ms ease;
}
.create-request-btn.secondary {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
box-shadow: none;
}
.create-request-btn:hover {
transform: translateY(-1px);
box-shadow: 0 14px 28px var(--theme-primary-shadow);
filter: saturate(1.02);
}
.create-request-btn.secondary:hover {
border-color: rgba(58, 124, 165, .32);
color: var(--theme-primary-active);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
}
.document-status-filter {
display: inline-flex;
align-items: center;
@@ -348,83 +182,6 @@
min-width: 154px;
}
.table-wrap {
min-height: 400px;
margin-top: 10px;
overflow-x: auto;
overflow-y: auto;
border: 1px solid #edf2f7;
border-radius: 10px;
background: linear-gradient(180deg, #fcfefd 0%, #f4f8f6 100%);
display: flex;
flex-direction: column;
}
.table-wrap.is-empty {
align-items: center;
justify-content: center;
}
.table-wrap table {
width: 100%;
align-self: flex-start;
}
.table-state {
width: 100%;
min-height: 260px;
display: grid;
place-items: center;
gap: 10px;
padding: 28px 20px;
text-align: center;
color: #64748b;
background: linear-gradient(180deg, #fcfffd 0%, #f5f9f7 100%);
}
.table-state .mdi {
font-size: 28px;
color: var(--theme-primary);
}
.table-state strong {
color: #0f172a;
font-size: 15px;
}
.table-state p {
max-width: 420px;
margin: 0;
font-size: 13px;
line-height: 1.6;
}
.table-state.error {
background: linear-gradient(180deg, #fffdfd 0%, #fff6f6 100%);
}
.table-state.error .mdi {
color: #ef4444;
}
.retry-btn {
height: 36px;
padding: 0 14px;
border: 1px solid #f1c5c5;
border-radius: 8px;
background: #fff;
color: #b91c1c;
font-size: 13px;
font-weight: 750;
}
table {
width: 100%;
min-width: 1420px;
border-collapse: collapse;
table-layout: fixed;
}
.col-id { width: 11%; }
.col-created { width: 10%; }
.col-stay { width: 9%; }
@@ -437,47 +194,6 @@ table {
.col-status { width: 8%; }
.col-updated { width: 9%; }
th,
td {
padding: 13px 12px;
border-bottom: 1px solid #edf2f7;
color: #24324a;
font-size: 14px;
line-height: 1.35;
text-align: center;
vertical-align: middle;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
th {
position: sticky;
top: 0;
z-index: 1;
background: #f7fafc;
color: #64748b;
font-size: 13px;
font-weight: 800;
}
tbody tr {
cursor: pointer;
}
tbody tr:hover {
background: linear-gradient(90deg, rgba(58, 124, 165, .08), rgba(58, 124, 165, .03));
}
tbody tr:last-child td {
border-bottom: 0;
}
.doc-id {
color: var(--theme-primary-active);
font-weight: 800;
}
.new-document-badge {
display: inline-flex;
align-items: center;
@@ -505,211 +221,16 @@ tbody tr:last-child td {
background: #ef4444;
}
.doc-kind-tag,
.type-tag,
.status-tag {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.doc-kind-tag {
min-height: 26px;
padding: 0 10px;
border-radius: 7px;
font-size: 12px;
font-weight: 800;
}
.doc-kind-tag.reimbursement {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.doc-kind-tag.application {
background: #eff6ff;
color: #2563eb;
}
.type-tag {
min-height: 26px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.type-tag.travel,
.type-tag.hotel,
.type-tag.transport {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.type-tag.entertainment,
.type-tag.meal {
background: #fff7ed;
color: #ea580c;
}
.type-tag.office {
background: #eff6ff;
color: #2563eb;
}
.type-tag.meeting,
.type-tag.training {
background: #eef2ff;
color: #4f46e5;
}
.type-tag.other {
background: #f8fafc;
color: #475569;
}
.status-tag {
min-height: 24px;
padding: 0 9px;
border: 1px solid transparent;
border-radius: 6px;
font-size: 12px;
font-weight: 750;
}
.status-tag.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.status-tag.success,
.status-tag.archived {
border-color: var(--success-line);
background: var(--success-soft);
color: var(--success-active);
}
.status-tag.warning,
.status-tag.draft {
border-color: #fed7aa;
background: #fff7ed;
color: #f97316;
}
.status-tag.danger {
border-color: #fecaca;
background: #fef2f2;
color: #dc2626;
}
.status-tag.neutral {
border-color: #cbd5e1;
background: #f8fafc;
color: #475569;
}
.list-foot {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
margin-top: 12px;
}
.page-summary {
color: #64748b;
font-size: 14px;
font-weight: 650;
}
.pager {
display: inline-flex;
justify-content: center;
gap: 6px;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
}
.pager button {
width: 32px;
height: 32px;
border: 0;
border-radius: 9px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 800;
}
.pager button:hover:not(.active) {
background: #fff;
color: var(--theme-primary-active);
box-shadow: 0 1px 4px rgba(15, 23, 42, .08);
}
.pager button.active {
background: var(--theme-primary-active);
color: #fff;
box-shadow: 0 8px 16px var(--theme-primary-shadow);
}
.pager button:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
.page-size-select {
width: 118px;
justify-self: end;
}
@media (max-width: 1200px) {
.document-toolbar,
.list-foot {
grid-template-columns: 1fr;
}
.document-toolbar {
align-items: stretch;
flex-direction: column;
}
.document-actions {
justify-content: flex-start;
}
}
@media (max-width: 760px) {
.documents-list {
padding: 16px;
}
.status-tabs {
gap: 18px;
overflow-x: auto;
}
.filter-set,
.document-actions,
.document-status-filter,
.list-search,
.filter-btn,
.page-size-select {
width: 100%;
}
.document-status-filter {
width: 100%;
align-items: stretch;
flex-direction: column;
gap: 6px;
}
.list-foot {
display: grid;
justify-items: stretch;
}
}

View File

@@ -0,0 +1,372 @@
.receipt-folder-page {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr);
animation: fadeUp 220ms var(--ease) both;
overflow: hidden;
}
.receipt-folder-list,
.receipt-folder-detail {
min-height: 0;
overflow: hidden;
padding: 16px 18px;
}
.receipt-folder-list {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
}
.receipt-detail-head,
.receipt-detail-foot,
.receipt-basic-panel header,
.receipt-preview-panel header,
.receipt-field-list-head {
display: flex;
align-items: center;
}
.receipt-form-grid input,
.receipt-form-grid textarea,
.receipt-field-row input {
width: 100%;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #0f172a;
font-size: 13px;
transition: border-color 160ms ease, box-shadow 160ms ease;
}
.receipt-form-grid input,
.receipt-field-row input {
height: 36px;
padding: 0 10px;
}
.receipt-form-grid textarea {
resize: vertical;
min-height: 78px;
padding: 9px 10px;
line-height: 1.55;
}
.receipt-form-grid input:focus,
.receipt-form-grid textarea:focus,
.receipt-field-row input:focus {
border-color: var(--theme-primary);
box-shadow: 0 0 0 3px rgba(58, 124, 165, 0.14);
outline: none;
}
.apply-btn,
.ghost-btn,
.danger-btn,
.back-btn {
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 4px;
font-size: 13px;
font-weight: 750;
white-space: nowrap;
}
.ghost-btn,
.back-btn {
padding: 0 13px;
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.apply-btn {
padding: 0 14px;
border: 1px solid var(--theme-primary);
background: var(--theme-primary);
color: #fff;
}
.danger-btn {
padding: 0 14px;
border: 1px solid #dc2626;
background: #dc2626;
color: #fff;
}
.apply-btn:disabled,
.danger-btn:disabled {
opacity: .55;
cursor: not-allowed;
}
.receipt-folder-detail {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.receipt-detail-head {
gap: 14px;
padding-bottom: 14px;
border-bottom: 1px solid #dbe4ee;
}
.receipt-detail-head h2 {
margin: 4px 0;
color: #0f172a;
font-size: 20px;
line-height: 1.25;
}
.receipt-detail-head p {
margin: 0;
color: #64748b;
font-size: 13px;
}
.assistant-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
border-radius: 4px;
background: #eef6ff;
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 850;
}
.detail-loading {
min-height: 0;
display: grid;
place-items: center;
}
.receipt-detail-layout {
min-height: 0;
display: grid;
grid-template-columns: minmax(360px, 0.95fr) minmax(420px, 1.05fr);
gap: 16px;
padding: 16px 0;
overflow: hidden;
}
.receipt-basic-panel,
.receipt-preview-panel {
min-height: 0;
overflow: hidden;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
}
.receipt-basic-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
padding: 14px;
overflow-y: auto;
}
.receipt-basic-panel header,
.receipt-preview-panel header,
.receipt-field-list-head {
justify-content: space-between;
gap: 12px;
}
.receipt-basic-panel header strong,
.receipt-preview-panel header strong,
.receipt-field-list-head strong {
color: #0f172a;
font-size: 15px;
}
.receipt-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.receipt-form-grid label {
display: grid;
gap: 6px;
}
.receipt-form-grid label span {
color: #64748b;
font-size: 12px;
font-weight: 750;
}
.field-wide {
grid-column: 1 / -1;
}
.receipt-field-list {
margin-top: 18px;
display: grid;
gap: 10px;
}
.receipt-field-row {
display: grid;
grid-template-columns: minmax(100px, .6fr) minmax(160px, 1fr) 30px;
gap: 8px;
}
.receipt-field-row button {
display: grid;
place-items: center;
border: 1px solid #d7e0ea;
border-radius: 4px;
background: #fff;
color: #64748b;
}
.receipt-preview-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
}
.receipt-preview-panel header {
padding: 14px;
border-bottom: 1px solid #e5edf5;
}
.preview-source-btn {
border: 0;
background: transparent;
color: var(--theme-primary-active);
font-size: 13px;
font-weight: 750;
text-decoration: none;
}
.receipt-preview-box {
min-height: 0;
display: grid;
place-items: center;
overflow: auto;
background: #f8fafc;
}
.receipt-preview-box img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.receipt-preview-box iframe {
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.preview-empty {
display: grid;
gap: 8px;
justify-items: center;
color: #64748b;
text-align: center;
}
.preview-empty .mdi {
color: var(--theme-primary);
font-size: 34px;
}
.receipt-detail-foot {
justify-content: space-between;
gap: 12px;
padding-top: 14px;
border-top: 1px solid #dbe4ee;
}
.associate-step {
display: grid;
gap: 12px;
}
.associate-hint {
margin: 0;
color: #64748b;
font-size: 13px;
}
.receipt-checkbox-list,
.draft-choice-list {
max-height: 360px;
display: grid;
gap: 8px;
overflow-y: auto;
}
.receipt-checkbox-list :deep(.el-checkbox),
.draft-choice {
min-height: 50px;
align-items: center;
margin-right: 0;
padding: 9px 10px;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
}
.receipt-checkbox-list :deep(.el-checkbox__label) {
display: grid;
gap: 3px;
color: #0f172a;
font-weight: 750;
}
.receipt-checkbox-list small,
.draft-choice small {
color: #64748b;
font-size: 12px;
font-weight: 650;
}
.draft-choice {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 9px;
cursor: pointer;
}
.draft-choice.active {
border-color: rgba(58, 124, 165, .42);
background: rgba(58, 124, 165, .07);
}
.draft-choice span {
display: grid;
gap: 3px;
}
@media (max-width: 1120px) {
.receipt-detail-layout {
grid-template-columns: 1fr;
overflow-y: auto;
}
.receipt-preview-panel {
min-height: 520px;
}
}
@media (max-width: 760px) {
.receipt-folder-list,
.receipt-folder-detail {
padding: 12px;
}
.receipt-form-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -440,13 +440,13 @@ watch(
.profile-tags-panel {
grid-template-rows: auto minmax(0, 1fr);
align-content: start;
align-content: stretch;
min-height: 352px;
}
.profile-radar-panel {
grid-template-rows: auto minmax(0, 1fr) auto;
align-content: start;
align-content: stretch;
min-height: 352px;
}
@@ -477,6 +477,15 @@ watch(
.profile-panel-empty {
margin: 0;
padding: 18px 12px;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
justify-self: stretch;
box-sizing: border-box;
min-height: 100%;
border: 1px dashed #cbd5e1;
border-radius: 4px;
background: #f8fafc;
@@ -487,10 +496,12 @@ watch(
text-align: center;
}
.profile-tags-panel > .profile-panel-empty {
min-height: 284px;
}
.profile-radar-empty {
min-height: 308px;
display: grid;
place-items: center;
}
.profile-operation-copy strong {

View File

@@ -156,9 +156,9 @@ const sidebarMeta = {
overview: { label: '分析看板' },
workbench: { label: '个人工作台' },
documents: { label: '单据中心' },
budget: { label: '预算中心' },
policies: { label: '知识管理' },
audit: { label: '规则中心' },
budget: { label: '预算编制' },
policies: { label: '财务政策' },
audit: { label: '规则管理' },
digitalEmployees: { label: '数字员工' },
employees: { label: '员工管理' },
settings: { label: '系统设置' }

View File

@@ -6,6 +6,7 @@ import { icons } from '../data/icons.js'
export const appViews = [
'workbench',
'documents',
'receiptFolder',
'budget',
'audit',
'overview',
@@ -32,20 +33,28 @@ export const navItems = [
title: '单据中心',
desc: '统一查看申请、报销、审批与归档。'
},
{
id: 'receiptFolder',
label: '票据夹',
navHint: '存放已上传并识别的原始票据',
icon: icons.receipt,
title: '票据夹',
desc: '集中查看未关联和已关联票据,避免 OCR 后票据丢失。'
},
{
id: 'budget',
label: '预算中心',
label: '预算编制',
navHint: '管理预算额度、预算占用与超预算预警',
icon: icons.budget,
title: '预算中心',
title: '预算编制',
desc: '配置部门、项目及费用类型预算,跟踪申请占用、报销核销与超预算预警。'
},
{
id: 'audit',
label: '规则中心',
label: '规则管理',
navHint: '查看和管理规则配置',
icon: icons.skill,
title: '规则中心',
title: '规则管理',
desc: '集中管理财务规则、风险规则与外部 MCP 服务。'
},
{
@@ -74,11 +83,11 @@ export const navItems = [
},
{
id: 'policies',
label: '制度知识',
navHint: '查看制度与知识库',
label: '财务政策',
navHint: '查看财务政策与制度文档',
icon: icons.library,
title: '制度与知识库',
desc: '统一管理制度文档、检索入口与知识资产。'
title: '财务政策',
desc: '统一管理财务政策文档、检索入口与知识资产。'
},
{
id: 'settings',
@@ -94,6 +103,7 @@ const viewRouteNames = {
overview: 'app-overview',
workbench: 'app-workbench',
documents: 'app-documents',
receiptFolder: 'app-receiptFolder',
budget: 'app-budget',
policies: 'app-policies',
audit: 'app-audit',

View File

@@ -9,6 +9,7 @@ export const icons = {
budget: iconPath('<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>'),
archive: iconPath('<path d="M3 7h18v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M3 7l2-3h14l2 3"/><path d="M10 12h4"/>'),
file: iconPath('<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M8 13h8"/><path d="M8 17h5"/>'),
receipt: iconPath('<path d="M5 3v18l2-1 2 1 2-1 2 1 2-1 2 1 2-1V3z"/><path d="M8 8h8"/><path d="M8 12h8"/><path d="M8 16h5"/>'),
book: iconPath('<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>'),
library: iconPath('<path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/><path d="M2 20h20"/>'),
skill: iconPath('<path d="M12 3 9.5 8.5 3 11l6.5 2.5L12 19l2.5-5.5L21 11l-6.5-2.5z"/><path d="M19 19l.9 2 .9-2 2-.9-2-.9-.9-2-.9 2-2 .9z"/><path d="M5 5l.6 1.4L7 7l-1.4.6L5 9l-.6-1.4L3 7l1.4-.6z"/>'),

View File

@@ -4,6 +4,7 @@ export function recognizeOcrFiles(files, options = {}) {
const formData = new FormData()
for (const file of files) {
formData.append('files', file)
formData.append('receipt_ids', String(file?.receiptId || ''))
}
return apiRequest('/ocr/recognize', {

View File

@@ -0,0 +1,49 @@
import { apiRequest } from './api.js'
function buildStatusQuery(status = 'all') {
const normalized = String(status || 'all').trim()
return normalized ? `?status=${encodeURIComponent(normalized)}` : ''
}
export function fetchReceiptFolderItems(status = 'all') {
return apiRequest(`/receipt-folder${buildStatusQuery(status)}`)
}
export function fetchReceiptFolderDetail(receiptId) {
return apiRequest(`/receipt-folder/${encodeURIComponent(String(receiptId || '').trim())}`)
}
export function updateReceiptFolderItem(receiptId, payload = {}) {
return apiRequest(`/receipt-folder/${encodeURIComponent(String(receiptId || '').trim())}`, {
method: 'PATCH',
body: JSON.stringify(payload)
})
}
export function deleteReceiptFolderItem(receiptId) {
return apiRequest(`/receipt-folder/${encodeURIComponent(String(receiptId || '').trim())}`, {
method: 'DELETE'
})
}
export function fetchReceiptFolderAsset(pathOrUrl) {
const target = String(pathOrUrl || '').trim()
if (!target) {
throw new Error('票据文件地址为空。')
}
return apiRequest(target, {
responseType: 'blob'
})
}
export async function buildReceiptFile(receipt) {
const blob = await fetchReceiptFolderAsset(receipt?.source_url || receipt?.sourceUrl)
const fileName = String(receipt?.file_name || receipt?.fileName || 'receipt.bin').trim() || 'receipt.bin'
const mediaType = String(receipt?.media_type || receipt?.mediaType || blob.type || 'application/octet-stream')
const file = new File([blob], fileName, { type: mediaType })
Object.defineProperty(file, 'receiptId', {
value: String(receipt?.id || ''),
enumerable: false
})
return file
}

View File

@@ -91,6 +91,9 @@ export function deleteExpenseClaimItem(claimId, itemId) {
export function uploadExpenseClaimItemAttachment(claimId, itemId, file) {
const formData = new FormData()
formData.append('file', file)
if (file?.receiptId) {
formData.append('receipt_id', String(file.receiptId))
}
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/items/${encodeURIComponent(String(itemId || '').trim())}/attachment`, {
method: 'POST',

View File

@@ -1,6 +1,7 @@
export const DEFAULT_APP_VIEW_ORDER = [
'workbench',
'documents',
'receiptFolder',
'budget',
'audit',
'overview',
@@ -10,7 +11,7 @@ export const DEFAULT_APP_VIEW_ORDER = [
'settings'
]
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'policies'])
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'documents', 'receiptFolder', 'policies'])
const VIEW_ROLE_RULES = {
overview: ['finance', 'executive'],
budget: ['budget_monitor', 'executive'],

View File

@@ -97,11 +97,15 @@ export function buildUserProfileMetricCards(profile, runs = [], currentUser = {}
const index = indexProfiles(profile)
const aiMetrics = metricsOf(index.ai_usage)
const userRuns = filterRunsByCurrentUser(runs, currentUser)
const durationDisplay = formatDurationMetric(sumRunDurationMs(userRuns))
const commonAgent = resolveCommonAgent(userRuns)
const windowedUserRuns = filterRunsByProfileWindow(userRuns, profile)
const durationMs = hasProfileDurationMetric(aiMetrics)
? resolveNumber(aiMetrics.ai_run_duration_ms)
: sumRunDurationMs(windowedUserRuns)
const durationDisplay = formatDurationMetric(durationMs)
const commonAgent = resolveCommonAgent(windowedUserRuns)
const tokenCount = resolveNumber(aiMetrics.exact_token_count) || resolveNumber(aiMetrics.estimated_token_count)
const tokenDisplay = formatTokenCount(tokenCount)
const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || userRuns.length
const aiRunCount = resolveNumber(aiMetrics.ai_run_count) || windowedUserRuns.length
return [
{
@@ -223,11 +227,23 @@ function metricsOf(profile) {
return profile?.metrics && typeof profile.metrics === 'object' ? profile.metrics : {}
}
function hasProfileDurationMetric(metrics) {
return Object.prototype.hasOwnProperty.call(metrics || {}, 'ai_run_duration_ms')
}
function filterRunsByCurrentUser(runs, currentUser) {
const identities = resolveCurrentUserIdentities(currentUser)
return (Array.isArray(runs) ? runs : []).filter((run) => belongsToCurrentUser(run, identities))
}
function filterRunsByProfileWindow(runs, profile) {
const cutoff = Date.now() - resolveWindowDays(profile) * 24 * 60 * 60 * 1000
return (Array.isArray(runs) ? runs : []).filter((run) => {
const startedAt = Date.parse(run?.started_at || '')
return Number.isFinite(startedAt) && startedAt >= cutoff
})
}
function belongsToCurrentUser(run, identities) {
if (!identities.size) {
return false

View File

@@ -40,6 +40,7 @@
'overview-main': activeView === 'overview',
'workbench-main': activeView === 'workbench',
'documents-main': activeView === 'documents',
'receipt-folder-main': activeView === 'receiptFolder',
'budget-main': activeView === 'budget',
'policies-main': activeView === 'policies',
'audit-main': activeView === 'audit',
@@ -75,7 +76,7 @@
/>
<FilterBar
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'digitalEmployees' && activeView !== 'employees' && activeView !== 'settings'"
v-if="activeView !== 'overview' && activeView !== 'workbench' && activeView !== 'documents' && activeView !== 'receiptFolder' && activeView !== 'budget' && activeView !== 'policies' && activeView !== 'audit' && activeView !== 'digitalEmployees' && activeView !== 'employees' && activeView !== 'settings'"
:compact="activeView === 'overview'"
:filters="filters"
:ranges="ranges"
@@ -87,6 +88,7 @@
class="workarea"
:class="{
'documents-workarea': activeView === 'documents',
'receipt-folder-workarea': activeView === 'receiptFolder',
'workbench-workarea': activeView === 'workbench',
'budget-workarea': activeView === 'budget',
'policies-workarea': activeView === 'policies',
@@ -133,6 +135,11 @@
@summary-change="documentSummary = $event"
/>
<ReceiptFolderView
v-else-if="activeView === 'receiptFolder'"
@open-assistant="openSmartEntry"
/>
<BudgetCenterView
v-else-if="activeView === 'budget'"
:current-user="currentUser"
@@ -190,6 +197,7 @@ const PersonalWorkbenchView = defineAsyncComponent(() => import('./PersonalWorkb
const TravelReimbursementCreateView = defineAsyncComponent(() => import('./TravelReimbursementCreateView.vue'))
const TravelRequestDetailView = defineAsyncComponent(() => import('./TravelRequestDetailView.vue'))
const DocumentsCenterView = defineAsyncComponent(() => import('./DocumentsCenterView.vue'))
const ReceiptFolderView = defineAsyncComponent(() => import('./ReceiptFolderView.vue'))
const BudgetCenterRouteLoading = {
name: 'BudgetCenterRouteLoading',
render: () =>

View File

@@ -832,4 +832,5 @@ onMounted(() => {
})
</script>
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
<style scoped src="../assets/styles/views/documents-center-view.css"></style>

View File

@@ -0,0 +1,620 @@
<template>
<section class="receipt-folder-page">
<article v-if="!detailMode" class="receipt-folder-list panel">
<nav class="status-tabs receipt-status-tabs" aria-label="票据关联状态">
<button
v-for="tab in receiptTabs"
:key="tab.value"
type="button"
:class="{ active: activeStatus === tab.value }"
@click="switchStatus(tab.value)"
>
<span>{{ tab.label }}</span>
<span v-if="tab.count > 0" class="scope-tab-badge">{{ tab.count > 99 ? '99+' : tab.count }}</span>
</button>
</nav>
<div class="document-toolbar">
<div class="filter-set">
<div class="list-search">
<i class="mdi mdi-magnify"></i>
<input v-model="keyword" type="search" placeholder="搜索文件名、票据类型、金额、关联单号..." />
</div>
<button class="filter-btn" type="button" @click="reloadReceipts">
<i class="mdi mdi-refresh"></i>
<span>刷新</span>
</button>
</div>
<div class="document-actions">
<button
class="create-request-btn"
type="button"
:disabled="!unlinkedReceipts.length"
@click="openAssociateDialog"
>
<i class="mdi mdi-link-variant-plus"></i>
<span>一键关联票据</span>
</button>
</div>
</div>
<div class="table-wrap" :class="{ 'is-empty': showEmpty }">
<div v-if="loading" class="table-state">
<TableLoadingState
title="票据夹加载中"
message="正在读取已上传票据、OCR 信息与关联状态"
icon="mdi mdi-receipt-text-outline"
floating
/>
</div>
<div v-else-if="error" class="table-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<strong>票据夹加载失败</strong>
<p>{{ error }}</p>
<button class="retry-btn" type="button" @click="reloadReceipts">重新加载</button>
</div>
<TableEmptyState
v-else-if="showEmpty"
eyebrow="票据夹"
:title="emptyTitle"
:description="emptyDesc"
icon="mdi mdi-receipt-text-outline"
tone="theme"
art-label="RECEIPT"
:tips="emptyTips"
/>
<table v-else>
<colgroup>
<col class="col-file">
<col class="col-kind">
<col class="col-scene">
<col class="col-money">
<col class="col-date">
<col class="col-score">
<col class="col-status">
<col class="col-updated">
</colgroup>
<thead>
<tr>
<th>票据文件</th>
<th>识别类型</th>
<th>费用场景</th>
<th>金额</th>
<th>票据日期</th>
<th>置信度</th>
<th>关联状态</th>
<th>上传时间</th>
</tr>
</thead>
<tbody>
<tr v-for="row in visibleRows" :key="row.id" @click="openDetail(row)">
<td>
<strong class="doc-id">{{ row.file_name }}</strong>
<small>{{ row.summary || '暂无摘要' }}</small>
</td>
<td><span class="doc-kind-tag reimbursement">{{ row.document_type_label }}</span></td>
<td><span class="type-tag neutral">{{ row.scene_label }}</span></td>
<td>{{ row.amount || '待补充' }}</td>
<td>{{ row.document_date || '待补充' }}</td>
<td>{{ formatScore(row.avg_score) }}</td>
<td>
<span class="status-tag" :class="row.status === 'linked' ? 'completed' : 'warning'">
{{ row.status_label }}
</span>
</td>
<td>{{ formatDateTime(row.uploaded_at) }}</td>
</tr>
</tbody>
</table>
</div>
<footer v-if="showTable" class="list-foot">
<span class="page-summary"> {{ filteredRows.length }} 目前第 {{ currentPage }} </span>
<div class="pager" aria-label="分页">
<button class="page-nav" type="button" :disabled="currentPage === 1" aria-label="上一页" @click="currentPage--">
<i class="mdi mdi-chevron-left"></i>
</button>
<button
v-for="page in totalPages"
:key="page"
class="page-number"
:class="{ active: currentPage === page }"
type="button"
@click="currentPage = page"
>
{{ page }}
</button>
<button class="page-nav" type="button" :disabled="currentPage === totalPages" aria-label="下一页" @click="currentPage++">
<i class="mdi mdi-chevron-right"></i>
</button>
</div>
<EnterpriseSelect v-model="pageSize" class="page-size-select" :options="pageSizeOptions" size="small" @change="currentPage = 1" />
</footer>
</article>
<article v-else class="receipt-folder-detail panel">
<header class="receipt-detail-head">
<button class="back-btn" type="button" @click="backToList">
<i class="mdi mdi-arrow-left"></i>
<span>返回票据夹</span>
</button>
<div>
<span class="assistant-badge">票据详情</span>
<h2>{{ detailForm.file_name }}</h2>
<p>{{ selectedReceipt?.summary || '核对并修正票据基础信息,后续关联报销单时会带入当前票据。' }}</p>
</div>
</header>
<div v-if="detailLoading" class="detail-loading">
<TableLoadingState title="票据详情加载中" message="正在读取票据源文件与 OCR 元数据" icon="mdi mdi-receipt-text-outline" floating />
</div>
<div v-else class="receipt-detail-layout">
<section class="receipt-basic-panel">
<header>
<strong>基本票据信息</strong>
<button class="apply-btn" type="button" :disabled="savingDetail" @click="saveDetail">
<i class="mdi mdi-content-save-outline"></i>
<span>{{ savingDetail ? '保存中' : '保存修改' }}</span>
</button>
</header>
<div class="receipt-form-grid">
<label>
<span>票据类型</span>
<input v-model="detailForm.document_type_label" type="text" />
</label>
<label>
<span>费用场景</span>
<input v-model="detailForm.scene_label" type="text" />
</label>
<label>
<span>金额</span>
<input v-model="detailForm.amount" type="text" placeholder="待补充" />
</label>
<label>
<span>票据日期</span>
<input v-model="detailForm.document_date" type="text" placeholder="YYYY-MM-DD" />
</label>
<label>
<span>商户</span>
<input v-model="detailForm.merchant_name" type="text" placeholder="待补充" />
</label>
<label>
<span>OCR 置信度</span>
<input :value="formatScore(selectedReceipt?.avg_score)" type="text" disabled />
</label>
<label class="field-wide">
<span>摘要</span>
<textarea v-model="detailForm.summary" rows="3" />
</label>
</div>
<div class="receipt-field-list">
<div class="receipt-field-list-head">
<strong>识别字段</strong>
<button class="ghost-btn" type="button" @click="addField">
<i class="mdi mdi-plus"></i>
<span>新增字段</span>
</button>
</div>
<div v-for="(field, index) in detailForm.fields" :key="`${field.key}-${index}`" class="receipt-field-row">
<input v-model="field.label" type="text" placeholder="字段名" />
<input v-model="field.value" type="text" placeholder="字段值" />
<button type="button" aria-label="删除字段" @click="removeField(index)">
<i class="mdi mdi-close"></i>
</button>
</div>
</div>
</section>
<section class="receipt-preview-panel">
<header>
<strong>原始文件</strong>
<button v-if="selectedReceipt?.source_url" class="preview-source-btn" type="button" @click="openSourceFile">
打开源文件
</button>
</header>
<div class="receipt-preview-box">
<img v-if="previewKind === 'image' && previewObjectUrl" :src="previewObjectUrl" alt="票据预览" />
<iframe v-else-if="previewKind === 'pdf' && previewObjectUrl" :src="previewObjectUrl" title="票据 PDF 预览"></iframe>
<div v-else class="preview-empty">
<i class="mdi mdi-file-eye-outline"></i>
<strong>当前文件暂不支持内嵌预览</strong>
<p>可以点击右上角打开源文件查看</p>
</div>
</div>
</section>
</div>
<footer class="receipt-detail-foot">
<button class="ghost-btn" type="button" @click="backToList">返回列表</button>
<button class="danger-btn" type="button" :disabled="deleting" @click="deleteCurrentReceipt">
<i class="mdi mdi-delete-outline"></i>
<span>{{ deleting ? '删除中' : '删除票据' }}</span>
</button>
</footer>
</article>
<ElDialog
v-model="associateDialogOpen"
class="receipt-associate-dialog"
title="一键关联票据"
width="680px"
append-to-body
>
<section v-if="associateStep === 1" class="associate-step">
<p class="associate-hint">选择需要归集的未关联票据</p>
<ElCheckboxGroup v-model="selectedReceiptIds" class="receipt-checkbox-list">
<ElCheckbox v-for="receipt in unlinkedReceipts" :key="receipt.id" :label="receipt.id">
<span>{{ receipt.file_name }}</span>
<small>{{ receipt.document_type_label }} · {{ receipt.amount || '金额待补充' }}</small>
</ElCheckbox>
</ElCheckboxGroup>
</section>
<section v-else class="associate-step">
<p class="associate-hint">选择未提交草稿或基于票据新建一张报销单</p>
<div class="draft-choice-list">
<label class="draft-choice" :class="{ active: targetDraftId === NEW_CLAIM_VALUE }">
<input v-model="targetDraftId" type="radio" :value="NEW_CLAIM_VALUE" />
<span>
<strong>新建报销单</strong>
<small>将选中的票据带入对话 AI 辅助填写信息</small>
</span>
</label>
<label
v-for="draft in draftClaims"
:key="draft.claimId"
class="draft-choice"
:class="{ active: targetDraftId === draft.claimId }"
>
<input v-model="targetDraftId" type="radio" :value="draft.claimId" />
<span>
<strong>{{ draft.claimNo }}</strong>
<small>{{ draft.reason }} · {{ draft.amountDisplay }}</small>
</span>
</label>
</div>
</section>
<template #footer>
<button class="ghost-btn" type="button" @click="closeAssociateDialog">取消</button>
<button v-if="associateStep === 2" class="ghost-btn" type="button" @click="associateStep = 1">上一步</button>
<button
class="apply-btn"
type="button"
:disabled="associateBusy || !canProceedAssociate"
@click="handleAssociatePrimary"
>
{{ associatePrimaryLabel }}
</button>
</template>
</ElDialog>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElCheckbox, ElCheckboxGroup } from 'element-plus/es/components/checkbox/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { fetchExpenseClaims } from '../services/reimbursements.js'
import {
buildReceiptFile,
deleteReceiptFolderItem,
fetchReceiptFolderAsset,
fetchReceiptFolderDetail,
fetchReceiptFolderItems,
updateReceiptFolderItem
} from '../services/receiptFolder.js'
const NEW_CLAIM_VALUE = '__new_claim__'
const pageSizeOptions = [10, 20, 50].map((size) => ({ label: `${size} 条/页`, value: size }))
const emit = defineEmits(['open-assistant'])
const activeStatus = ref('unlinked')
const keyword = ref('')
const receipts = ref([])
const loading = ref(false)
const error = ref('')
const currentPage = ref(1)
const pageSize = ref(20)
const selectedReceipt = ref(null)
const detailLoading = ref(false)
const savingDetail = ref(false)
const deleting = ref(false)
const previewObjectUrl = ref('')
const associateDialogOpen = ref(false)
const associateStep = ref(1)
const selectedReceiptIds = ref([])
const targetDraftId = ref(NEW_CLAIM_VALUE)
const draftClaims = ref([])
const associateBusy = ref(false)
const detailForm = reactive({
file_name: '',
document_type: '',
document_type_label: '',
scene_code: '',
scene_label: '',
summary: '',
amount: '',
document_date: '',
merchant_name: '',
fields: []
})
const detailMode = computed(() => Boolean(selectedReceipt.value))
const unlinkedReceipts = computed(() => receipts.value.filter((item) => item.status !== 'linked'))
const linkedReceipts = computed(() => receipts.value.filter((item) => item.status === 'linked'))
const receiptTabs = computed(() => [
{ value: 'unlinked', label: '未关联票据', count: unlinkedReceipts.value.length },
{ value: 'linked', label: '已关联票据', count: linkedReceipts.value.length }
])
const activeRows = computed(() => (
activeStatus.value === 'linked' ? linkedReceipts.value : unlinkedReceipts.value
))
const filteredRows = computed(() => {
const normalized = keyword.value.trim().toLowerCase()
if (!normalized) return activeRows.value
return activeRows.value.filter((item) => [
item.file_name,
item.document_type_label,
item.scene_label,
item.summary,
item.amount,
item.document_date,
item.linked_claim_no
].filter(Boolean).join('').toLowerCase().includes(normalized))
})
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
const visibleRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
const showEmpty = computed(() => !loading.value && !error.value && visibleRows.value.length === 0)
const showTable = computed(() => !loading.value && !error.value && visibleRows.value.length > 0)
const emptyTitle = computed(() => keyword.value.trim() ? '没有符合条件的票据' : `${activeStatus.value === 'linked' ? '已关联票据' : '未关联票据'}为空`)
const emptyDesc = computed(() => activeStatus.value === 'linked'
? '已关联到报销单的票据会显示在这里,方便后续回溯。'
: '上传并完成 OCR 的票据会先进入这里,稍后可以再关联到报销草稿。'
)
const emptyTips = computed(() => activeStatus.value === 'linked'
? ['可从报销明细或票据夹关联流程形成已关联票据', '点击票据可查看原始文件与识别字段']
: ['票据不会因为未立即建单而丢失', '可多选票据后一次性带入报销对话']
)
const previewKind = computed(() => selectedReceipt.value?.preview_kind || '')
const canProceedAssociate = computed(() => (
associateStep.value === 1
? selectedReceiptIds.value.length > 0
: Boolean(targetDraftId.value)
))
const associatePrimaryLabel = computed(() => {
if (associateBusy.value) return '处理中'
return associateStep.value === 1 ? '下一步' : '进入关联对话'
})
watch([activeStatus, keyword, pageSize], () => {
currentPage.value = 1
})
onMounted(() => {
void reloadReceipts()
})
onBeforeUnmount(() => {
revokePreviewUrl()
})
function switchStatus(status) {
activeStatus.value = status
}
async function reloadReceipts() {
loading.value = true
error.value = ''
try {
receipts.value = await fetchReceiptFolderItems('all')
} catch (err) {
error.value = err?.message || '票据夹加载失败。'
} finally {
loading.value = false
}
}
async function openDetail(row) {
selectedReceipt.value = row
detailLoading.value = true
revokePreviewUrl()
try {
const detail = await fetchReceiptFolderDetail(row.id)
selectedReceipt.value = detail
fillDetailForm(detail)
await loadPreview(detail)
} catch (err) {
error.value = err?.message || '票据详情加载失败。'
selectedReceipt.value = null
} finally {
detailLoading.value = false
}
}
function fillDetailForm(detail) {
detailForm.file_name = detail.file_name || ''
detailForm.document_type = detail.document_type || ''
detailForm.document_type_label = detail.document_type_label || ''
detailForm.scene_code = detail.scene_code || ''
detailForm.scene_label = detail.scene_label || ''
detailForm.summary = detail.summary || ''
detailForm.amount = detail.amount || ''
detailForm.document_date = detail.document_date || ''
detailForm.merchant_name = detail.merchant_name || ''
detailForm.fields = Array.isArray(detail.fields)
? detail.fields.map((field) => ({ ...field }))
: []
}
async function loadPreview(detail) {
if (!detail?.preview_url) return
try {
const blob = await fetchReceiptFolderAsset(detail.preview_url)
previewObjectUrl.value = URL.createObjectURL(blob)
} catch {
previewObjectUrl.value = ''
}
}
function revokePreviewUrl() {
if (previewObjectUrl.value) {
URL.revokeObjectURL(previewObjectUrl.value)
previewObjectUrl.value = ''
}
}
async function openSourceFile() {
if (!selectedReceipt.value?.source_url) return
const blob = await fetchReceiptFolderAsset(selectedReceipt.value.source_url)
const objectUrl = URL.createObjectURL(blob)
window.open(objectUrl, '_blank', 'noopener,noreferrer')
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30000)
}
function backToList() {
selectedReceipt.value = null
revokePreviewUrl()
}
function addField() {
detailForm.fields.push({ key: '', label: '', value: '' })
}
function removeField(index) {
detailForm.fields.splice(index, 1)
}
async function saveDetail() {
if (!selectedReceipt.value?.id || savingDetail.value) return
savingDetail.value = true
try {
const updated = await updateReceiptFolderItem(selectedReceipt.value.id, {
document_type: detailForm.document_type,
document_type_label: detailForm.document_type_label,
scene_code: detailForm.scene_code,
scene_label: detailForm.scene_label,
summary: detailForm.summary,
amount: detailForm.amount,
document_date: detailForm.document_date,
merchant_name: detailForm.merchant_name,
fields: detailForm.fields
})
selectedReceipt.value = updated
fillDetailForm(updated)
await reloadReceipts()
} finally {
savingDetail.value = false
}
}
async function deleteCurrentReceipt() {
if (!selectedReceipt.value?.id || deleting.value) return
deleting.value = true
try {
await deleteReceiptFolderItem(selectedReceipt.value.id)
backToList()
await reloadReceipts()
} finally {
deleting.value = false
}
}
async function openAssociateDialog() {
selectedReceiptIds.value = []
targetDraftId.value = NEW_CLAIM_VALUE
associateStep.value = 1
associateDialogOpen.value = true
await loadDraftClaims()
}
function closeAssociateDialog() {
if (associateBusy.value) return
associateDialogOpen.value = false
}
async function loadDraftClaims() {
try {
const claims = await fetchExpenseClaims()
draftClaims.value = (Array.isArray(claims) ? claims : [])
.filter((claim) => String(claim.status || '').trim().toLowerCase() === 'draft')
.map((claim) => ({
raw: claim,
claimId: String(claim.id || '').trim(),
claimNo: String(claim.claim_no || '').trim(),
reason: String(claim.reason || '待补充事由').trim(),
amountDisplay: `${Number(claim.amount || 0).toFixed(2)} ${claim.currency || 'CNY'}`
}))
.filter((claim) => claim.claimId)
} catch {
draftClaims.value = []
}
}
async function handleAssociatePrimary() {
if (associateStep.value === 1) {
associateStep.value = 2
return
}
await openAssociationConversation()
}
async function openAssociationConversation() {
if (associateBusy.value || !selectedReceiptIds.value.length) return
associateBusy.value = true
try {
const selected = receipts.value.filter((item) => selectedReceiptIds.value.includes(item.id))
const files = await Promise.all(selected.map((item) => buildReceiptFile(item)))
const selectedDraft = draftClaims.value.find((item) => item.claimId === targetDraftId.value)
const prompt = selectedDraft
? `请把票据夹中选中的 ${files.length} 份票据关联到报销草稿 ${selectedDraft.claimNo},并继续核对填写信息。`
: `请基于票据夹中选中的 ${files.length} 份票据新建一张报销草稿,并继续核对填写信息。`
associateDialogOpen.value = false
emit('open-assistant', {
source: selectedDraft ? 'detail' : 'receipt-folder',
request: selectedDraft
? {
...selectedDraft.raw,
claimId: selectedDraft.claimId,
documentNo: selectedDraft.claimNo
}
: null,
prompt,
files
})
} finally {
associateBusy.value = false
}
}
function formatScore(value) {
const score = Number(value || 0)
if (!Number.isFinite(score) || score <= 0) return '待确认'
return `${Math.round(score * 100)}%`
}
function formatDateTime(value) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '待确认'
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
</script>
<style scoped src="../assets/styles/components/document-list-shared.css"></style>
<style scoped src="../assets/styles/views/receipt-folder-view.css"></style>

View File

@@ -21,12 +21,21 @@ function testFallsBackToValidMeta() {
function testResolvesMainRouteNames() {
assert.equal(resolveTargetRouteName('logs'), 'app-settings')
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
assert.equal(resolveTargetRouteName('receiptFolder'), 'app-receiptFolder')
assert.equal(resolveTargetRouteName('requests'), 'app-overview')
assert.equal(resolveTargetRouteName('approval'), 'app-overview')
assert.equal(resolveTargetRouteName('archive'), 'app-overview')
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
}
function testReceiptFolderFollowsDocumentCenter() {
assert.equal(appViews[appViews.indexOf('documents') + 1], 'receiptFolder')
const documentIndex = navItems.findIndex((item) => item.id === 'documents')
const receiptIndex = navItems.findIndex((item) => item.id === 'receiptFolder')
assert.equal(receiptIndex, documentIndex + 1)
assert.equal(navItems[receiptIndex].label, '票据夹')
}
function testLegacyCentersAreRemovedFromNavigation() {
assert.equal(appViews.includes('requests'), false)
assert.equal(appViews.includes('approval'), false)
@@ -39,6 +48,7 @@ function run() {
testDerivesViewFromRouteName()
testFallsBackToValidMeta()
testResolvesMainRouteNames()
testReceiptFolderFollowsDocumentCenter()
testLegacyCentersAreRemovedFromNavigation()
console.log('navigation route resolution tests passed')
}

View File

@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const root = process.cwd()
function readProjectFile(path) {
return readFileSync(join(root, path), 'utf8')
}
function testReceiptFolderViewSurface() {
const view = readProjectFile('web/src/views/ReceiptFolderView.vue')
assert.match(view, /未关联票据/)
assert.match(view, /已关联票据/)
assert.match(view, /一键关联票据/)
assert.match(view, /基本票据信息/)
assert.match(view, /原始文件/)
assert.match(view, /返回列表/)
assert.match(view, /删除票据/)
assert.match(view, /ElCheckboxGroup/)
assert.match(view, /fetchReceiptFolderItems\('all'\)/)
assert.match(view, /buildReceiptFile\(item\)/)
assert.match(view, /source: selectedDraft \? 'detail' : 'receipt-folder'/)
assert.match(view, /emit\('open-assistant'/)
}
function testReceiptFolderServiceContract() {
const service = readProjectFile('web/src/services/receiptFolder.js')
const ocrService = readProjectFile('web/src/services/ocr.js')
const reimbursementService = readProjectFile('web/src/services/reimbursements.js')
assert.match(service, /\/receipt-folder\$\{buildStatusQuery\(status\)\}/)
assert.match(service, /\/receipt-folder\/\$\{encodeURIComponent/)
assert.match(service, /responseType: 'blob'/)
assert.match(service, /new File\(\[blob\], fileName/)
assert.match(service, /receiptId/)
assert.match(ocrService, /formData\.append\('receipt_ids'/)
assert.match(reimbursementService, /formData\.append\('receipt_id'/)
}
function testAppShellWiresReceiptFolder() {
const shell = readProjectFile('web/src/views/AppShellRouteView.vue')
assert.match(shell, /activeView === 'receiptFolder'/)
assert.match(shell, /ReceiptFolderView/)
assert.match(shell, /@open-assistant="openSmartEntry"/)
assert.match(shell, /receipt-folder-workarea/)
}
function testSharedDocumentListStyleReuse() {
const receiptView = readProjectFile('web/src/views/ReceiptFolderView.vue')
const documentView = readProjectFile('web/src/views/DocumentsCenterView.vue')
const receiptStyles = readProjectFile('web/src/assets/styles/views/receipt-folder-view.css')
const sharedStyles = readProjectFile('web/src/assets/styles/components/document-list-shared.css')
assert.match(receiptView, /document-list-shared\.css/)
assert.match(documentView, /document-list-shared\.css/)
assert.match(sharedStyles, /\.table-wrap\b/)
assert.match(sharedStyles, /\.doc-kind-tag\b/)
assert.match(sharedStyles, /\.list-foot\b/)
assert.doesNotMatch(receiptStyles, /\.table-wrap\b/)
assert.doesNotMatch(receiptStyles, /\.doc-kind-tag\b/)
assert.doesNotMatch(receiptStyles, /\.list-foot\b/)
}
function run() {
testReceiptFolderViewSurface()
testReceiptFolderServiceContract()
testAppShellWiresReceiptFolder()
testSharedDocumentListStyleReuse()
console.log('receipt folder view tests passed')
}
run()